@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.
- 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/package.json +168 -82
- package/poi-lookup.ts +319 -0
- package/poi-schema.ts +147 -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 +429 -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/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
|
+
}
|
package/candidate-fts.ts
ADDED
|
@@ -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
|
+
}
|