@mailwoman/resolver-wof-sqlite 7.2.0 → 7.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/address-point-interpolation.ts +207 -0
  2. package/address-point-schema.ts +107 -0
  3. package/address-point.ts +122 -0
  4. package/ancestry-backfill.ts +205 -0
  5. package/ancestry.ts +70 -0
  6. package/build-candidate.ts +351 -0
  7. package/build-slim.ts +394 -0
  8. package/candidate-fts.ts +43 -0
  9. package/candidate-lookup.ts +382 -0
  10. package/candidate-schema.ts +166 -0
  11. package/coincident-roles.ts +240 -0
  12. package/convention.ts +152 -0
  13. package/fst-autocomplete.ts +187 -0
  14. package/fst-builder.ts +291 -0
  15. package/fst-deserialize-web.ts +164 -0
  16. package/fst-matcher.ts +150 -0
  17. package/fst-serialize.ts +311 -0
  18. package/fst-types.ts +78 -0
  19. package/fts.ts +318 -0
  20. package/geo.ts +140 -0
  21. package/geonames-aliases.ts +317 -0
  22. package/geonames-postal.ts +150 -0
  23. package/index.ts +117 -0
  24. package/interpolation.ts +232 -0
  25. package/lookup.ts +1498 -0
  26. package/package.json +168 -82
  27. package/poi-lookup.ts +319 -0
  28. package/poi-schema.ts +147 -0
  29. package/postal-city-alias-lookup.ts +89 -0
  30. package/postal-city-alias-schema.ts +75 -0
  31. package/postal-city-candidate-schema.ts +81 -0
  32. package/postcode-point-lookup.ts +64 -0
  33. package/reverse.ts +429 -0
  34. package/schema.ts +176 -0
  35. package/sharding.ts +235 -0
  36. package/sqlite-convention-source.ts +61 -0
  37. package/sqlite-utils.ts +25 -0
  38. package/street-centroid-schema.ts +124 -0
  39. package/street-centroid.ts +124 -0
  40. package/street-morphology-fst-builder.ts +230 -0
  41. package/street-name-lookup.ts +101 -0
  42. package/street-normalize.ts +302 -0
  43. package/street-segment-schema.ts +104 -0
  44. package/types.ts +164 -0
  45. package/unified-schema.ts +171 -0
package/build-slim.ts ADDED
@@ -0,0 +1,394 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Build a "slim" Who's On First SQLite distribution that's small enough to ship as a static asset
7
+ * for the browser-side mailwoman demo (Path B of the demo plan). The full admin distribution is
8
+ * ~2 GB; the slim variant aims for the ~50–100 MB range by keeping only the places a public demo
9
+ * will actually query for.
10
+ *
11
+ * Selection policy (v1, US-focused):
12
+ *
13
+ * - All countries / regions / counties / boroughs in the configured `countries` set, so the ancestor
14
+ * chain a locality / postcode reports through `parent_id` stays intact.
15
+ * - Top-K localities by population (read from the source's pre-built `place_population` aux table) in
16
+ * those countries.
17
+ * - All postcodes in those countries — they're small and addressing-relevant.
18
+ * - All `names` + `place_population` rows for selected place IDs.
19
+ * - The `coincident_roles` dual-role relation (#402), filtered to surviving spr ids.
20
+ *
21
+ * No geojson, by design. The upstream WOF GeoJSON bodies live ONLY in the raw `whosonfirst-data-*`
22
+ * repos; `scripts/build-unified-wof.ts` extracts `wof:population` straight into
23
+ * `place_population` (and the bbox into `spr`) at ingest and never persists a `geojson` table. So
24
+ * the source admin DB carries population in `place_population`, and this builder consumes it
25
+ * directly — there is nothing to extract from, and nothing to drop.
26
+ *
27
+ * The output DB has the resolver-facing schema: `spr`, `names`, `place_population`, plus the
28
+ * `place_search` FTS5 / `place_bbox` R*Tree virtual tables rebuilt against the trimmed row set
29
+ * (both derive purely from `spr` + `names` — see `fts.ts`). That means `WOFSqlitePlaceLookup`
30
+ * opens the slim DB without any code change — it sees a smaller universe, nothing more.
31
+ *
32
+ * Multi-shard inputs (e.g. admin + postcode) are processed in sequence; selected rows accumulate
33
+ * into the single output DB. The postcode shard contributes only postcodes; admin contributes
34
+ * everything else. Empty / missing input paths are skipped (callers pass `""` when a shard, such
35
+ * as a custom postcode DB, isn't built yet).
36
+ */
37
+
38
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs"
39
+ import { tmpdir } from "node:os"
40
+ import { join } from "node:path"
41
+ import { DatabaseSync } from "node:sqlite"
42
+
43
+ import { SqliteDialect } from "@mailwoman/core/kysley/dialect"
44
+ import { sealDatabase } from "@mailwoman/core/utils"
45
+ import { Kysely, sql } from "kysely"
46
+
47
+ import { buildPlaceSearchFTS, PLACE_BBOX_TABLE, PLACE_POPULATION_TABLE, PLACE_SEARCH_TABLE } from "./fts.ts"
48
+ import type { NamesTable, SprTable } from "./schema.ts"
49
+
50
+ export interface BuildSlimOptions {
51
+ /** Input WOF SQLite distributions. Each should already have spr / names / place_population tables. */
52
+ inputs: string[]
53
+ /** Output path for the slim DB. Will be overwritten if it exists. */
54
+ output: string
55
+ /** Country codes to keep (ISO 2-letter). Defaults to `["US"]`. */
56
+ countries?: string[]
57
+ /** Cap on the number of localities to keep per country, by descending population. */
58
+ topLocalitiesPerCountry?: number
59
+ /**
60
+ * Drop the `names` table after the FTS index is built (default false). `place_search` is a self-contained FTS5 (no
61
+ * external `content=`), so once it's built `names` is only the build-time source — the resolver queries
62
+ * `place_search` + `spr` + `place_population` + `coincident_roles` and never reads `names` at runtime. Dropping it is
63
+ * the single biggest size win (~2/3 of the file for a multi-locale build; see #359). A future consumer that needs raw
64
+ * alt-names at runtime should ship a SEPARATE shard rather than re-bloat the hot DB.
65
+ */
66
+ dropNames?: boolean
67
+ /** Optional progress callback for CLI / test introspection. */
68
+ onProgress?: (phase: SlimBuildPhase, detail: string) => void
69
+ }
70
+
71
+ export type SlimBuildPhase =
72
+ | "init"
73
+ | "schema"
74
+ | "country"
75
+ | "region"
76
+ | "county"
77
+ | "locality"
78
+ | "postcode"
79
+ | "names"
80
+ | "place_population"
81
+ | "coincident_roles"
82
+ | "place_abbr"
83
+ | "fts"
84
+ | "vacuum"
85
+ | "done"
86
+
87
+ export interface BuildSlimResult {
88
+ outputPath: string
89
+ outputBytes: number
90
+ rowCounts: {
91
+ spr: number
92
+ names: number
93
+ placeSearch: number
94
+ placeBbox: number
95
+ placePopulation: number
96
+ }
97
+ }
98
+
99
+ /** Placetypes that we always keep so the ancestor chain a selected locality reports stays valid. */
100
+ const ANCESTOR_PLACETYPES = ["country", "region", "county", "borough", "macroregion"] as const
101
+
102
+ /** Tables copied verbatim (schema + filtered rows) from each source DB. Anything else is dropped. */
103
+ const COPIED_TABLES = ["spr", "names", PLACE_POPULATION_TABLE] as const
104
+
105
+ /** Fallback DDL for `place_population` when the first source predates the aux table (defensive). */
106
+ const PLACE_POPULATION_DDL = `CREATE TABLE ${PLACE_POPULATION_TABLE} (id INTEGER PRIMARY KEY, population INTEGER NOT NULL DEFAULT 0)`
107
+
108
+ /** Minimal row shape for the population aux table — id + population, nothing else. */
109
+ interface PlacePopulationTable {
110
+ id: number
111
+ population: number
112
+ }
113
+
114
+ /**
115
+ * Kysely schema for the build phase. Mirrors the resolver-facing tables, plus the ATTACHed `src.*` tables so the
116
+ * row-copying queries can name the source schema in `selectFrom` without falling back to raw SQL. ATTACH itself is
117
+ * still raw — Kysely doesn't model it — but everything downstream (the SELECT-INSERT step that does the actual
118
+ * filtering work) goes through the builder.
119
+ */
120
+ interface BuildSchema {
121
+ spr: SprTable
122
+ names: NamesTable
123
+ place_population: PlacePopulationTable
124
+ "src.spr": SprTable
125
+ "src.names": NamesTable
126
+ "src.place_population": PlacePopulationTable
127
+ }
128
+
129
+ export async function buildSlimWOFDatabase(opts: BuildSlimOptions): Promise<BuildSlimResult> {
130
+ const countries = (opts.countries ?? ["US"]).map((c) => c.toUpperCase())
131
+ const topLocalities = opts.topLocalitiesPerCountry ?? 1000
132
+ const progress = opts.onProgress ?? (() => {})
133
+
134
+ // Callers pass `""` for shards that don't exist yet (e.g. a not-yet-built custom postcode DB).
135
+ // Skip empties up front; require every remaining path to exist.
136
+ const inputs = opts.inputs.filter((p) => p.length > 0)
137
+
138
+ if (inputs.length === 0) throw new Error("no input WOF dbs provided")
139
+
140
+ for (const input of inputs) {
141
+ if (!existsSync(input)) throw new Error(`input WOF db not found: ${input}`)
142
+ }
143
+
144
+ progress("init", `${inputs.length} input(s) → ${opts.output}`)
145
+
146
+ if (existsSync(opts.output)) {
147
+ rmSync(opts.output)
148
+ }
149
+
150
+ // Open the output DB and create the empty schema. We discover the schema from the FIRST input
151
+ // (raw sqlite_master read — Kysely doesn't model that) so the output mirrors source column
152
+ // ordering / types. `CREATE TABLE AS SELECT` flattens types to dynamic, which would break
153
+ // callers that rely on column-affinity behavior.
154
+ const out = new DatabaseSync(opts.output)
155
+ let result: BuildSlimResult
156
+
157
+ try {
158
+ const firstSource = new DatabaseSync(inputs[0]!, { readOnly: true })
159
+
160
+ try {
161
+ progress("schema", "copying spr / names / place_population schemas from first input")
162
+
163
+ for (const table of COPIED_TABLES) {
164
+ const createSQL = firstSource
165
+ .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`)
166
+ .get(table) as { sql?: string } | undefined
167
+
168
+ if (createSQL?.sql) {
169
+ // Raw DDL by design (introspect-and-replay): we exec the SOURCE DB's own CREATE TABLE
170
+ // string read from sqlite_master, so a static Kysely builder can't express it. See AGENTS.md.
171
+ out.exec(createSQL.sql)
172
+ } else if (table === PLACE_POPULATION_TABLE) {
173
+ // Older source builds may predate the aux table — create it empty so the per-source
174
+ // copy + ranking have somewhere to land. Sparse-by-design; missing rows are fine.
175
+ out.exec(PLACE_POPULATION_DDL)
176
+ } else {
177
+ throw new Error(`source DB ${inputs[0]} is missing required table '${table}'`)
178
+ }
179
+ }
180
+ // PRIMARY KEY on spr.id + place_population.id come from the schemas we copied; an explicit
181
+ // index on names.id helps the per-id INSERT SELECT later.
182
+ out.exec(`CREATE INDEX IF NOT EXISTS names_id_idx ON names(id);`)
183
+ } finally {
184
+ firstSource.close()
185
+ }
186
+
187
+ const kysely = new Kysely<BuildSchema>({ dialect: new SqliteDialect({ database: out }) })
188
+
189
+ // Pull rows from each input.
190
+ for (const inputPath of inputs) {
191
+ await copyFromSource(out, kysely, inputPath, countries, topLocalities, progress)
192
+ }
193
+
194
+ // Build the resolver virtual tables on the trimmed row set. Both place_search (FTS5) and
195
+ // place_bbox (R*Tree) derive purely from spr + names — no geojson needed (see fts.ts). The
196
+ // population aux table is NOT rebuilt here: it was copied verbatim above, and fts.ts only
197
+ // (re)builds it when a `geojson` table is present, which the slim DB intentionally has not.
198
+ progress("fts", "building place_search / place_bbox on slim DB")
199
+ buildPlaceSearchFTS(out, {
200
+ drop: true, // schema we copied had no FTS tables, but be explicit
201
+ onProgress: (phase, name) => progress("fts", `${phase} ${name}`),
202
+ })
203
+
204
+ // Materialize region/state ABBREVIATIONS into a standalone `place_abbr (id, abbr)` table BEFORE
205
+ // `names` is (optionally) dropped. The full DB lets the resolver tier an exact-abbrev match by
206
+ // querying `names` (`#exactMatchIds`), but the slim DB drops `names` for size — so the
207
+ // browser resolver gets its own tiny lookup (~hundreds of rows) to do the same data-driven
208
+ // exact-abbrev tiering ("VT" → Vermont, not a token-matching foreign region) instead of the
209
+ // demo's hardcoded `expandUSRegion` map. Sourced from the `language='abbr'` rows
210
+ // `add-region-abbrevs.ts` wrote, already filtered to surviving spr ids via the names copy. The
211
+ // table is always created (empty when the source predates the abbrev enrichment) so the
212
+ // resolver can query it unconditionally.
213
+ progress("place_abbr", "materializing region abbreviations")
214
+ out.exec(`CREATE TABLE IF NOT EXISTS place_abbr (id INTEGER NOT NULL, abbr TEXT NOT NULL)`)
215
+ out.exec(`INSERT INTO place_abbr (id, abbr) SELECT id, name FROM names WHERE language = 'abbr'`)
216
+ out.exec(`CREATE INDEX IF NOT EXISTS place_abbr_by_abbr ON place_abbr (abbr COLLATE NOCASE)`)
217
+ out.exec(`CREATE INDEX IF NOT EXISTS place_abbr_by_id ON place_abbr (id)`)
218
+
219
+ // Capture the names count BEFORE any drop so the build report stays informative.
220
+ const namesRows = countRows(out, "names")
221
+
222
+ // Optionally drop `names` (+ its index) now that the self-contained FTS5 index no longer needs
223
+ // it. The resolver never reads `names` at query time, so this is pure size reduction.
224
+ if (opts.dropNames) {
225
+ progress("vacuum", `dropping names table (${namesRows} rows; FTS5 is self-contained)`)
226
+ out.exec(`DROP INDEX IF EXISTS names_id_idx;`)
227
+ out.exec(`DROP TABLE IF EXISTS names;`)
228
+ }
229
+
230
+ // VACUUM the output so the on-disk file reflects just the trimmed row count. Without it the
231
+ // file size stays inflated from the in-flight INSERT churn.
232
+ progress("vacuum", "VACUUM (final size reduction)")
233
+ out.exec("VACUUM;")
234
+
235
+ const rowCounts = {
236
+ spr: countRows(out, "spr"),
237
+ names: namesRows,
238
+ placeSearch: countRows(out, PLACE_SEARCH_TABLE),
239
+ placeBbox: countRows(out, PLACE_BBOX_TABLE),
240
+ placePopulation: countRows(out, PLACE_POPULATION_TABLE),
241
+ }
242
+ progress("done", JSON.stringify(rowCounts))
243
+
244
+ result = {
245
+ outputPath: opts.output,
246
+ outputBytes: statSync(opts.output).size,
247
+ rowCounts,
248
+ }
249
+ } finally {
250
+ out.close()
251
+ }
252
+ // The sealed-artifact invariant: a built DB is a read-only asset from the moment it exists.
253
+ sealDatabase(opts.output)
254
+
255
+ return result
256
+ }
257
+
258
+ async function copyFromSource(
259
+ out: DatabaseSync,
260
+ kysely: Kysely<BuildSchema>,
261
+ inputPath: string,
262
+ countries: string[],
263
+ topLocalities: number,
264
+ progress: NonNullable<BuildSlimOptions["onProgress"]>
265
+ ): Promise<void> {
266
+ // ATTACH avoids any "load source into memory" step — SQLite walks both files in place. We need
267
+ // a fresh temp copy because some WOF distributions ship as read-only filesystem mounts and
268
+ // ATTACH will still want a writable journal on the side; copying to /tmp dodges that without
269
+ // mutating the canonical files in /mnt/playpen/mailwoman-data/wof/. ATTACH / DETACH stay raw
270
+ // — Kysely doesn't model them.
271
+ const tmpScratch = mkdtempSync(join(tmpdir(), "mailwoman-slim-src-"))
272
+ const scratchPath = join(tmpScratch, "src.db")
273
+ copyFileSync(inputPath, scratchPath)
274
+
275
+ try {
276
+ out.exec(`ATTACH DATABASE '${scratchPath.replace(/'/g, "''")}' AS src;`)
277
+
278
+ try {
279
+ // Does this shard carry the pre-built population aux table? The admin source does; a bare
280
+ // postcode shard might not. The locality ranking + population copy below adapt accordingly.
281
+ const srcHasPopulation = Boolean(
282
+ out.prepare(`SELECT 1 FROM src.sqlite_master WHERE type = 'table' AND name = '${PLACE_POPULATION_TABLE}'`).get()
283
+ )
284
+
285
+ // The SELECT-INSERT queries below go through Kysely. The cross-schema FROM is the only
286
+ // "interesting" bit: by declaring `src.spr` / `src.names` / `src.place_population` in
287
+ // `BuildSchema`, Kysely lets us write `selectFrom("src.spr")` with the same column-type
288
+ // checking as the regular schema. SQLite parses the dotted identifier as a schema-name
289
+ // qualifier, so this works directly without any aliasing trick.
290
+
291
+ // 1. Ancestor placetypes (country / region / county / etc.) — always-kept.
292
+ progress("country", `${inputPath}: ancestor placetypes in (${countries.join(",")})`)
293
+ await kysely
294
+ .insertInto("spr")
295
+ .expression((eb) =>
296
+ eb
297
+ .selectFrom("src.spr")
298
+ .selectAll()
299
+ .where("is_current", "!=", 0)
300
+ .where("is_deprecated", "=", 0)
301
+ .where("country", "in", countries)
302
+ .where("placetype", "in", [...ANCESTOR_PLACETYPES])
303
+ )
304
+ .onConflict((oc) => oc.doNothing())
305
+ .execute()
306
+
307
+ // 2. Top-K localities by population. Population lives in the pre-built `place_population`
308
+ // aux table — left-join it so localities without a population row still qualify (sorted
309
+ // last). If the shard has no population table, fall back to a deterministic id ordering.
310
+ progress("locality", `${inputPath}: top-${topLocalities} localities by population`)
311
+ await kysely
312
+ .insertInto("spr")
313
+ .expression((eb) =>
314
+ eb
315
+ .selectFrom("src.spr as s")
316
+ .$if(srcHasPopulation, (qb) => qb.leftJoin("src.place_population as p", "p.id", "s.id"))
317
+ .selectAll("s")
318
+ .where("s.is_current", "!=", 0)
319
+ .where("s.is_deprecated", "=", 0)
320
+ .where("s.country", "in", countries)
321
+ .where("s.placetype", "=", "locality")
322
+ .orderBy(srcHasPopulation ? sql<number>`COALESCE(p.population, 0)` : sql<number>`s.id`, "desc")
323
+ .limit(topLocalities)
324
+ )
325
+ .onConflict((oc) => oc.doNothing())
326
+ .execute()
327
+
328
+ // 3. All postcodes in scope.
329
+ progress("postcode", `${inputPath}: all postcodes`)
330
+ await kysely
331
+ .insertInto("spr")
332
+ .expression((eb) =>
333
+ eb
334
+ .selectFrom("src.spr")
335
+ .selectAll()
336
+ .where("is_current", "!=", 0)
337
+ .where("is_deprecated", "=", 0)
338
+ .where("country", "in", countries)
339
+ .where("placetype", "=", "postalcode")
340
+ )
341
+ .onConflict((oc) => oc.doNothing())
342
+ .execute()
343
+
344
+ // 4. Pull names for the IDs we just selected.
345
+ progress("names", `${inputPath}: names rows for selected IDs`)
346
+ await kysely
347
+ .insertInto("names")
348
+ .expression((eb) => eb.selectFrom("src.names").selectAll().where("id", "in", eb.selectFrom("spr").select("id")))
349
+ .onConflict((oc) => oc.doNothing())
350
+ .execute()
351
+
352
+ // 5. Pull population rows for the selected IDs (sparse — only the places WOF has a count for).
353
+ if (srcHasPopulation) {
354
+ progress("place_population", `${inputPath}: population rows for selected IDs`)
355
+ await kysely
356
+ .insertInto("place_population")
357
+ .expression((eb) =>
358
+ eb.selectFrom("src.place_population").selectAll().where("id", "in", eb.selectFrom("spr").select("id"))
359
+ )
360
+ .onConflict((oc) => oc.doNothing())
361
+ .execute()
362
+ }
363
+
364
+ // 6. Carry the coincident_roles relation (#402) when this source has it (the admin DB), so the
365
+ // slim/demo DB supports dual-role hierarchy completion (on by default). Filtered to surviving
366
+ // spr ids → no orphans. Tiny (~hundreds of rows). `ancestors` is intentionally NOT copied (huge
367
+ // + build-only), so we copy the derived table rather than rebuild it. Raw SQL — conditional +
368
+ // not in the Kysely build schema.
369
+ const relationSchema = out
370
+ .prepare(`SELECT sql FROM src.sqlite_master WHERE type = 'table' AND name = 'coincident_roles'`)
371
+ .get() as { sql?: string } | undefined
372
+
373
+ if (relationSchema?.sql) {
374
+ progress("coincident_roles", `${inputPath}: copying dual-role relation`)
375
+ out.exec(relationSchema.sql.replace(/CREATE TABLE/i, "CREATE TABLE IF NOT EXISTS"))
376
+ out.exec(
377
+ `INSERT OR IGNORE INTO coincident_roles SELECT * FROM src.coincident_roles
378
+ WHERE admin_id IN (SELECT id FROM spr) AND locality_id IN (SELECT id FROM spr)`
379
+ )
380
+ out.exec(`CREATE INDEX IF NOT EXISTS coincident_roles_by_admin ON coincident_roles (admin_id)`)
381
+ }
382
+ } finally {
383
+ out.exec(`DETACH DATABASE src;`)
384
+ }
385
+ } finally {
386
+ rmSync(tmpScratch, { recursive: true, force: true })
387
+ }
388
+ }
389
+
390
+ function countRows(db: DatabaseSync, table: string): number {
391
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n?: number } | undefined
392
+
393
+ return Number(row?.n ?? 0)
394
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * FTS5-TRIGRAM fuzzy index over the candidate gazetteer's `name_key` — the typo-tolerant fallback
7
+ * the exact `name_key` B-tree probe structurally can't do (a misspelling breaks the normalized
8
+ * key, so the contiguous-probe lookup returns nothing). It indexes the NORMALIZED key (not the
9
+ * raw `name`), so a diacritic-stripped query (`munchen`) trigram-matches the stored `munchen`
10
+ * rather than missing a raw `München`. The trigram tokenizer makes MATCH a substring/fuzzy
11
+ * operation; the reader ({@link WOFCandidateTableLookup}) OR's the query's trigrams to fetch a
12
+ * loose set, then re-ranks by the SAME `trigramJaccard` the admin/FTS backend uses, so a typo
13
+ * resolves identically on either.
14
+ *
15
+ * This is what unifies the two gazetteers: the candidate B-tree stays the common,
16
+ * byte-range-optimal fast path (the browser's contiguous probe), and FTS5 is consulted ONLY on an
17
+ * exact+strip miss — so its scattered postings cost is rare/amortized, and one DB serves both the
18
+ * browser and the server.
19
+ *
20
+ * Raw SQL on purpose: Kysely can't express `CREATE VIRTUAL TABLE … USING fts5` (the repo's
21
+ * FTS5-stays-raw rule). Indexing DISTINCT `name_key` keeps the index to unique normalized forms
22
+ * (a name_key fans out to many candidate rows — placetypes, regions, aliases — but the fuzzy
23
+ * fallback only needs to recover the name_key, then re-probes the B-tree for its rows).
24
+ */
25
+
26
+ import type { DatabaseSync } from "node:sqlite"
27
+
28
+ /**
29
+ * Name of the FTS5 trigram virtual table this module owns. The reader gates its fuzzy fallback on it.
30
+ */
31
+ export const CANDIDATE_FTS_TABLE = "candidate_fts"
32
+
33
+ /**
34
+ * Build (or rebuild) {@link CANDIDATE_FTS_TABLE} from the materialized `candidate` table. Call after the candidate
35
+ * B-tree is populated (build pipeline) or against an existing candidate DB (migration).
36
+ */
37
+ export function createCandidateFTS(db: DatabaseSync): void {
38
+ db.exec(`DROP TABLE IF EXISTS ${CANDIDATE_FTS_TABLE}`)
39
+ db.exec(`CREATE VIRTUAL TABLE ${CANDIDATE_FTS_TABLE} USING fts5(name_key, tokenize='trigram')`)
40
+ db.exec(
41
+ `INSERT INTO ${CANDIDATE_FTS_TABLE}(name_key) SELECT DISTINCT name_key FROM candidate WHERE name_key IS NOT NULL`
42
+ )
43
+ }