@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/sharding.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Multi-shard support for `WOFSqlitePlaceLookup` — opens multiple WOF SQLite distributions on one
7
+ * connection via `ATTACH DATABASE`, and routes queries to the right shard based on placetype.
8
+ *
9
+ * ## The FTS5 syntax rule that drove this design
10
+ *
11
+ * The naive `SELECT … FROM pc.place_search WHERE pc.place_search MATCH ?` fails — SQLite parses the
12
+ * schema-qualified table on the left of MATCH as "column place_search of table pc". Discovered in
13
+ * the spike at PR review time; documented as `_SHARD_RULE.md` should it ever bite again.
14
+ *
15
+ * The working form: schema-qualified in FROM, bare table name in MATCH:
16
+ *
17
+ * ```sql
18
+ * SELECT … FROM pc.place_search WHERE place_search MATCH ?
19
+ * ```
20
+ *
21
+ * Identical table names across attached shards (which is what we have — every shard ships its own
22
+ * `place_search` + `place_bbox`) are fine because the bare-name MATCH resolves against FROM
23
+ * scope.
24
+ */
25
+
26
+ import { basename } from "node:path"
27
+
28
+ /**
29
+ * Derive a SQL-safe schema name from a WOF distribution filename. Used by `ATTACH DATABASE … AS <name>` so each shard
30
+ * gets a stable, predictable handle.
31
+ *
32
+ * Convention strips the `whosonfirst-data-` prefix and the `-latest.db` (or just `.db`) suffix, then replaces `-` with
33
+ * `_` for SQL identifier safety.
34
+ *
35
+ * Examples:
36
+ *
37
+ * - `whosonfirst-data-admin-us-latest.db` → `admin_us`
38
+ * - `whosonfirst-data-postalcode-us-latest.db` → `postalcode_us`
39
+ * - `whosonfirst-data-admin-latest.db` → `admin`
40
+ * - `my-custom.db` → `my_custom`
41
+ *
42
+ * Callers can override the derived name explicitly via `ShardConfig.schemaName` when the filename doesn't follow WOF
43
+ * convention.
44
+ */
45
+ export function deriveSchemaName(path: string): string {
46
+ const stem = basename(path)
47
+ .replace(/^whosonfirst-data-/u, "")
48
+ .replace(/-latest\.db$/u, "")
49
+ .replace(/\.db$/u, "")
50
+ .replace(/[^a-zA-Z0-9_]/g, "_")
51
+
52
+ if (!stem) {
53
+ throw new Error(`deriveSchemaName: could not derive a SQL schema name from path ${JSON.stringify(path)}`)
54
+ }
55
+
56
+ return stem
57
+ }
58
+
59
+ /**
60
+ * Per-shard configuration. The simple form is just a path string — the schema name is derived from it. The object form
61
+ * lets callers override the derived schema name (useful when a filename doesn't follow WOF convention) or attach an
62
+ * extra hint about which placetypes route here.
63
+ */
64
+ export interface ShardConfig {
65
+ path: string
66
+ /**
67
+ * Override the auto-derived schema name. Useful when the filename doesn't match WOF convention or when you want a
68
+ * memorable handle. Must be a valid SQLite identifier — `[a-zA-Z_][a-zA-Z0-9_]*`.
69
+ */
70
+ schemaName?: string
71
+ /**
72
+ * Optional explicit list of placetypes this shard serves. When set, queries against any listed placetype are routed
73
+ * to this shard. When omitted, routing falls back to a name-match heuristic: a shard whose `schemaName` contains the
74
+ * placetype as a substring (e.g. `postalcode_us` for `postalcode` queries) is preferred for that placetype.
75
+ */
76
+ placetypes?: readonly string[]
77
+ }
78
+
79
+ /**
80
+ * Resolved post-derivation: paired path + chosen schema name + (possibly empty) placetypes hint. Used internally by
81
+ * `WOFSqlitePlaceLookup` so the routing logic operates on uniform structures.
82
+ */
83
+ export interface ResolvedShard {
84
+ path: string
85
+ schemaName: string
86
+ placetypes: readonly string[]
87
+ }
88
+
89
+ /** SQLite identifier regex — `[A-Za-z_][A-Za-z0-9_]*`. */
90
+ const SQLITE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/u
91
+
92
+ /**
93
+ * Normalize the user-provided `databasePath` opt (which may be a single string, an array of strings, or an array of
94
+ * `ShardConfig` objects) into a uniform `ResolvedShard[]`.
95
+ *
96
+ * The first shard becomes `main` regardless of its derived schema name — that's the SQLite convention. Subsequent
97
+ * shards keep their derived (or override) schema name.
98
+ */
99
+ export function resolveShards(input: string | ReadonlyArray<string | ShardConfig>): ResolvedShard[] {
100
+ const list = typeof input === "string" ? [input] : input
101
+
102
+ if (list.length === 0) throw new Error("resolveShards: at least one shard is required")
103
+
104
+ const seen = new Set<string>()
105
+ const out: ResolvedShard[] = []
106
+
107
+ for (let i = 0; i < list.length; i++) {
108
+ const entry = list[i]!
109
+ const cfg: ShardConfig = typeof entry === "string" ? { path: entry } : entry
110
+ const derived = cfg.schemaName ?? deriveSchemaName(cfg.path)
111
+
112
+ if (!SQLITE_IDENT_RE.test(derived)) {
113
+ throw new Error(
114
+ `resolveShards: schema name ${JSON.stringify(derived)} is not a valid SQLite identifier ` +
115
+ `(derived from path ${JSON.stringify(cfg.path)}). Pass an explicit ` +
116
+ `{ path, schemaName } to override.`
117
+ )
118
+ }
119
+ // The first shard is always main per SQLite semantics — its derived name is informational
120
+ // only. Subsequent shards must have unique non-main names.
121
+ const schemaName = i === 0 ? "main" : derived
122
+
123
+ if (i > 0 && (schemaName === "main" || seen.has(schemaName))) {
124
+ throw new Error(
125
+ `resolveShards: schema name ${JSON.stringify(schemaName)} collides ` +
126
+ `(either with "main" or another shard). Pass an explicit { path, schemaName }.`
127
+ )
128
+ }
129
+ seen.add(schemaName)
130
+ out.push({
131
+ path: cfg.path,
132
+ schemaName,
133
+ placetypes: cfg.placetypes ?? [],
134
+ })
135
+ }
136
+
137
+ return out
138
+ }
139
+
140
+ /**
141
+ * Pick the shard to route a query to given the requested placetype(s).
142
+ *
143
+ * Routing rules, in order:
144
+ *
145
+ * 1. If any shard has explicit `placetypes` that includes the requested placetype, use it.
146
+ * 2. Otherwise, if a non-main shard's `schemaName` matches the placetype (e.g. `postalcode_us` matches `postalcode`), use
147
+ * it.
148
+ * 3. Otherwise, fall back to `main`.
149
+ *
150
+ * This deliberately doesn't UNION across shards — BM25 scores aren't comparable across separately- indexed corpora, and
151
+ * the typical mailwoman query has a single placetype anyway. If a caller needs cross-shard results they can issue two
152
+ * `findPlace` calls.
153
+ */
154
+ /**
155
+ * All placetype-matching shards, in routing order (the country-aware pick chooses among these). Used by the bias path:
156
+ * a country-less postcode query with proximity hints fans out across every matching shard and merges, because
157
+ * single-shard routing would hide the cross-country ambiguity the hints exist to resolve ("48026" lives in
158
+ * postalcode-us AND postalcode-intl).
159
+ */
160
+ export function pickShardsForPlacetype(shards: ResolvedShard[], placetype: string | undefined): ResolvedShard[] {
161
+ if (!placetype) return [shards[0]!]
162
+ const matches: ResolvedShard[] = []
163
+
164
+ for (const s of shards) {
165
+ if (s.placetypes.includes(placetype)) {
166
+ matches.push(s)
167
+ }
168
+ }
169
+
170
+ for (const s of shards) {
171
+ if (s.schemaName === "main" || matches.includes(s)) continue
172
+
173
+ if (
174
+ s.schemaName === placetype ||
175
+ s.schemaName.startsWith(`${placetype}_`) ||
176
+ s.schemaName.endsWith(`_${placetype}`)
177
+ ) {
178
+ matches.push(s)
179
+ }
180
+ }
181
+
182
+ return matches.length > 0 ? matches : [shards[0]!]
183
+ }
184
+
185
+ export function pickShardForPlacetype(
186
+ shards: ResolvedShard[],
187
+ placetype: string | undefined,
188
+ opts?: {
189
+ /**
190
+ * #920: the query's country constraint, when the caller has one. With MULTIPLE shards matching a placetype
191
+ * (postalcode-us + postalcode-geonames-tail), first-match routing sent every postcode query to the first shard and
192
+ * starved the rest — a FI postcode could never reach the tail shard. When `country` is given and a matching shard's
193
+ * probed country set contains it, that shard wins; shards without the country are skipped; the placetype-match
194
+ * order remains the tiebreak when no shard claims the country (or none was probed).
195
+ */
196
+ country?: string
197
+ /** Per-schema probed country sets (see `WOFSqlitePlaceLookup`'s construction probe). */
198
+ countriesBySchema?: ReadonlyMap<string, ReadonlySet<string>>
199
+ }
200
+ ): ResolvedShard {
201
+ if (!placetype) return shards[0]!
202
+
203
+ const matches: ResolvedShard[] = []
204
+
205
+ for (const s of shards) {
206
+ if (s.placetypes.includes(placetype)) {
207
+ matches.push(s)
208
+ }
209
+ }
210
+
211
+ for (const s of shards) {
212
+ if (s.schemaName === "main" || matches.includes(s)) continue
213
+
214
+ // Substring match: `postalcode_us` matches `postalcode`. Conservative — requires the
215
+ // placetype to appear at a word boundary in the schema name to avoid false hits like
216
+ // `region` matching `arboregion`.
217
+ if (
218
+ s.schemaName === placetype ||
219
+ s.schemaName.startsWith(`${placetype}_`) ||
220
+ s.schemaName.endsWith(`_${placetype}`)
221
+ ) {
222
+ matches.push(s)
223
+ }
224
+ }
225
+
226
+ if (matches.length === 0) return shards[0]!
227
+
228
+ if (opts?.country && opts.countriesBySchema) {
229
+ for (const s of matches) {
230
+ if (opts.countriesBySchema.get(s.schemaName)?.has(opts.country)) return s
231
+ }
232
+ }
233
+
234
+ return matches[0]!
235
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `SqliteConventionSource` — a `ConventionSource` backed by the build-from-source convention asset
7
+ * (#290, Direction E). Conventions live in a read-only, provenance-stamped `address_convention`
8
+ * table keyed by WOF polygon id; this source queries them ON DEMAND by id (one indexed lookup,
9
+ * memoized) rather than paging the whole table into memory as a code constant — the deliberate
10
+ * counter to the Pelias "giant dictionary in RAM, no provenance" pattern (see the operator design
11
+ * value in memory `feedback-no-load-bearing-trivia`).
12
+ *
13
+ * The asset is the queryable, distributable artifact; the strategy IMPLEMENTATIONS stay in code. An
14
+ * unknown strategy NAME is surfaced loudly at dispatch (see `lookup.ts`), not silently
15
+ * swallowed.
16
+ */
17
+
18
+ import type { DatabaseSync } from "node:sqlite"
19
+
20
+ import { ADDRESS_CONVENTION_TABLE, type Convention, type ConventionSource } from "./convention.ts"
21
+
22
+ export class SqliteConventionSource implements ConventionSource {
23
+ readonly #db: DatabaseSync
24
+ readonly #schema: string
25
+ /** Memoize per-id lookups (including misses, as `null`) so a hot ancestor chain is queried once. */
26
+ readonly #cache = new Map<number, Convention | null>()
27
+
28
+ /**
29
+ * @param db An open handle to a DB that has the convention asset attached (or is it).
30
+ * @param schema The schema name the `address_convention` table lives under (`main` or an ATTACHed shard name —
31
+ * `WOFSqlitePlaceLookup` auto-detects which shard carries the table).
32
+ */
33
+ constructor(db: DatabaseSync, schema: string) {
34
+ this.#db = db
35
+ this.#schema = schema
36
+ }
37
+
38
+ get(wofID: number): Convention | undefined {
39
+ const cached = this.#cache.get(wofID)
40
+
41
+ if (cached !== undefined) return cached ?? undefined
42
+ let value: Convention | null = null
43
+
44
+ try {
45
+ const row = this.#db
46
+ .prepare(`SELECT convention FROM ${this.#schema}.${ADDRESS_CONVENTION_TABLE} WHERE wof_id = ?`)
47
+ .get(wofID) as { convention: string } | undefined
48
+
49
+ if (row?.convention) {
50
+ value = JSON.parse(row.convention) as Convention
51
+ }
52
+ } catch {
53
+ // Malformed JSON or a missing table → treat as no override (the chain falls back to
54
+ // WORLD_DEFAULT). The build script validates structure, so this is purely defensive.
55
+ value = null
56
+ }
57
+ this.#cache.set(wofID, value)
58
+
59
+ return value ?? undefined
60
+ }
61
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Small shared helpers for the SQLite-backed lookups.
7
+ */
8
+
9
+ import type { DatabaseSync } from "node:sqlite"
10
+
11
+ /**
12
+ * True when `name` is a table in the open database. The street-level lookups use this to degrade gracefully on an
13
+ * empty/tableless shard — an interrupted `build-*-shard.ts`, or a stray 0-byte file (e.g. `sqlite3 <missing>.db "…"`
14
+ * CREATES one) — rather than throwing `no such table` at construction and taking down a whole state's geocode (#568). A
15
+ * missing table makes the lookup a no-op miss.
16
+ */
17
+ export function hasTable(db: DatabaseSync, name: string): boolean {
18
+ try {
19
+ const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(name)
20
+
21
+ return row !== undefined
22
+ } catch {
23
+ return false
24
+ }
25
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Typed schema for the DERIVED STREET-CENTROID shard (`street-centroids-<cc>.db`, built by
7
+ * `ban/scripts/build-street-centroid-shard.ts` — the #1042 street-level tier behind "street-only
8
+ * FR queries deserve a street-level answer"). The shard is a `GROUP BY street` roll-up of the
9
+ * sealed rooftop address-point shard: one row per (street, postcode, commune) carrying the street's
10
+ * CENTROID + bounding-box EXTENT + member-point count. No new data source — a derived artifact.
11
+ *
12
+ * Single source of truth for the columns shared by the BUILDER (a positional prepared INSERT for
13
+ * throughput) and the READER ({@link StreetCentroidSqliteLookup}), so a column rename in one is a
14
+ * compile error in the other — the same discipline as `address-point-schema.ts`.
15
+ *
16
+ * Probe scopes (most-selective first): by `postcode`, else by `locality_base` (the
17
+ * arrondissement-stripped commune — see `stripArrondissement`; BAN names Paris/Lyon/Marseille rows
18
+ * per arrondissement, but a query names the base commune). The reader WEIGHTED-aggregates across the
19
+ * matched rows (by `point_count`) so a locality-scope probe returns the street's grand centroid over
20
+ * every postcode/arrondissement it spans.
21
+ */
22
+
23
+ import type { Kysely } from "kysely"
24
+
25
+ /**
26
+ * One street roll-up. `(street_norm, postcode, locality_base)` is unique. `lat`/`lon` are the UNWEIGHTED mean of the
27
+ * group's member address points (each source row = one point), so a cross-group weighted mean (`SUM(lat*point_count) /
28
+ * SUM(point_count)`) reconstructs the grand centroid. `min_/max_lat/lon` are the group's extent (the reader turns the
29
+ * bbox diagonal into an honest `uncertainty_m`).
30
+ */
31
+ export interface StreetCentroidTable {
32
+ /** Shared `normalizeStreetForKeyLocale` of the street — the build/query-consistent probe key. */
33
+ street_norm: string
34
+ /** The 5-digit postcode of this group, or null when the source row carried none. */
35
+ postcode: string | null
36
+ /** Arrondissement-stripped commune (`stripArrondissement(normalizeLocalityForKey(commune))`) — the fallback scope. */
37
+ locality_base: string
38
+ /** Weighted-mean centroid latitude of the street's member points. */
39
+ lat: number
40
+ /** Weighted-mean centroid longitude of the street's member points. */
41
+ lon: number
42
+ min_lat: number
43
+ max_lat: number
44
+ min_lon: number
45
+ max_lon: number
46
+ /** Member address-point count — the weight for a cross-group centroid aggregate. */
47
+ point_count: number
48
+ /** A representative street name as it appeared in the source (display / debugging). */
49
+ street_raw: string
50
+ /** Provenance: the register this street was derived from (e.g. `ban:fr`). */
51
+ source: string
52
+ /** The pinned data release the underlying points came from. */
53
+ release: string
54
+ /**
55
+ * #727 phase-4c: `foldStreetSurface(street_raw)` — the contract-fold street-NAME existence key for
56
+ * {@link StreetLocalityEvidence}. Distinct from `street_norm` (the `street-normalize` geocoding key): the
57
+ * name-evidence rerank folds the model's street surface with the SAME `foldStreetSurface` used to build this column
58
+ * (the fold-parity contract), so it must not drift from `street_norm`'s richer normalizer. Indexed (`idx_sc_name`)
59
+ * for a direct seek.
60
+ */
61
+ name_key: string
62
+ }
63
+
64
+ /** The street-centroid database schema for `new DatabaseClient<StreetCentroidDatabase>(...)`. */
65
+ export interface StreetCentroidDatabase {
66
+ street_centroid: StreetCentroidTable
67
+ }
68
+
69
+ /**
70
+ * The `street_centroid` columns in INSERT order. The builder's positional prepared statement derives its placeholder
71
+ * list from this, so the positional order can't drift from the DDL / the reader.
72
+ */
73
+ export const STREET_CENTROID_COLUMNS = [
74
+ "street_norm",
75
+ "postcode",
76
+ "locality_base",
77
+ "lat",
78
+ "lon",
79
+ "min_lat",
80
+ "max_lat",
81
+ "min_lon",
82
+ "max_lon",
83
+ "point_count",
84
+ "street_raw",
85
+ "source",
86
+ "release",
87
+ "name_key",
88
+ ] as const
89
+
90
+ /** Create the `street_centroid` table — called before the streaming bulk load. */
91
+ export async function createStreetCentroidTable(db: Kysely<StreetCentroidDatabase>): Promise<void> {
92
+ await db.schema
93
+ .createTable("street_centroid")
94
+ .addColumn("street_norm", "text", (c) => c.notNull())
95
+ .addColumn("postcode", "text")
96
+ .addColumn("locality_base", "text", (c) => c.notNull())
97
+ .addColumn("lat", "real", (c) => c.notNull())
98
+ .addColumn("lon", "real", (c) => c.notNull())
99
+ .addColumn("min_lat", "real", (c) => c.notNull())
100
+ .addColumn("max_lat", "real", (c) => c.notNull())
101
+ .addColumn("min_lon", "real", (c) => c.notNull())
102
+ .addColumn("max_lon", "real", (c) => c.notNull())
103
+ .addColumn("point_count", "integer", (c) => c.notNull())
104
+ .addColumn("street_raw", "text", (c) => c.notNull())
105
+ .addColumn("source", "text", (c) => c.notNull())
106
+ .addColumn("release", "text", (c) => c.notNull())
107
+ .addColumn("name_key", "text", (c) => c.notNull())
108
+ .execute()
109
+ }
110
+
111
+ /**
112
+ * Create the probe indexes: the two geocoding-scope indexes (postcode, locality-base) the resolver reader relies on,
113
+ * plus `idx_sc_name` — the #727 phase-4c name-existence key for a direct `name_key = ?` seek (the unscoped fragment
114
+ * lookup; without it that query skip-scans `idx_sc_postcode` at ~5 ms/probe).
115
+ */
116
+ export async function createStreetCentroidIndexes(db: Kysely<StreetCentroidDatabase>): Promise<void> {
117
+ await db.schema.createIndex("idx_sc_postcode").on("street_centroid").columns(["postcode", "street_norm"]).execute()
118
+ await db.schema
119
+ .createIndex("idx_sc_locality")
120
+ .on("street_centroid")
121
+ .columns(["locality_base", "street_norm"])
122
+ .execute()
123
+ await db.schema.createIndex("idx_sc_name").on("street_centroid").columns(["name_key"]).execute()
124
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * SQLite implementation of core's `StreetCentroidLookup` (#1042): the street-level tier BELOW the
7
+ * exact address-point tier and ABOVE admin-centroid resolution. Given a street name (no house
8
+ * number) plus a postcode/commune scope, it returns the street's CENTROID + an honest extent-derived
9
+ * uncertainty from the derived `street-centroids-<cc>.db` roll-up.
10
+ *
11
+ * Query-side normalization is THE shared normalizer (`street-normalize.ts`), selected per the shard's
12
+ * `streetLocale`, so build-side and probe-side keys agree by construction. The commune scope folds
13
+ * through `normalizeLocalityForKey` + `stripArrondissement` (BAN names Paris/Lyon/Marseille per
14
+ * arrondissement; a query names the base commune).
15
+ *
16
+ * Scope order is most-selective first: `postcode`, then the base commune. Each scope WEIGHTED-
17
+ * aggregates (by `point_count`) across the matched rows in SQL, so a commune-scope probe returns the
18
+ * street's grand centroid over every postcode/arrondissement it spans — one row, one hit. Matching is
19
+ * exact-after-normalization only (no fuzzy street matching in this tier).
20
+ */
21
+
22
+ import { DatabaseSync } from "node:sqlite"
23
+
24
+ import type { StreetCentroidHit, StreetCentroidLookup } from "@mailwoman/resolver"
25
+
26
+ import { hasTable } from "./sqlite-utils.ts"
27
+ import {
28
+ normalizeLocalityForKey,
29
+ normalizeStreetForKeyLocale,
30
+ type StreetLocale,
31
+ stripArrondissement,
32
+ } from "./street-normalize.ts"
33
+
34
+ /** The weighted-centroid + extent + provenance an aggregate probe projects. `lat` is null when nothing matched. */
35
+ interface AggRow {
36
+ lat: number | null
37
+ lon: number | null
38
+ min_lat: number | null
39
+ max_lat: number | null
40
+ min_lon: number | null
41
+ max_lon: number | null
42
+ source: string | null
43
+ release: string | null
44
+ }
45
+
46
+ /** Weighted-centroid aggregate over a WHERE-filtered set. `SUM(coord*n)/SUM(n)` reconstructs the grand centroid. */
47
+ const AGG_SELECT =
48
+ "SUM(lat * point_count) / SUM(point_count) AS lat, " +
49
+ "SUM(lon * point_count) / SUM(point_count) AS lon, " +
50
+ "MIN(min_lat) AS min_lat, MAX(max_lat) AS max_lat, MIN(min_lon) AS min_lon, MAX(max_lon) AS max_lon, " +
51
+ "MAX(source) AS source, MAX(release) AS release"
52
+
53
+ /** Half the bbox diagonal, in METERS — an honest coarse radius for a street centroid. */
54
+ function extentRadiusM(minLat: number, maxLat: number, minLon: number, maxLon: number): number {
55
+ const R = 6_371_000
56
+ const toRad = (d: number): number => (d * Math.PI) / 180
57
+ const dLat = toRad(maxLat - minLat)
58
+ const dLon = toRad(maxLon - minLon)
59
+ const midLat = toRad((minLat + maxLat) / 2)
60
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(midLat) ** 2 * Math.sin(dLon / 2) ** 2
61
+ const diag = 2 * R * Math.asin(Math.min(1, Math.sqrt(a)))
62
+
63
+ return Math.round(diag / 2)
64
+ }
65
+
66
+ export class StreetCentroidSqliteLookup implements StreetCentroidLookup {
67
+ readonly #db: DatabaseSync
68
+ readonly #locale: StreetLocale
69
+ readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
70
+ readonly #byLocality: ReturnType<DatabaseSync["prepare"]> | undefined
71
+
72
+ /**
73
+ * @param dbPath Shard path.
74
+ * @param opts.streetLocale The street-normalization locale this shard was BUILT with — must match, or every key
75
+ * misses. Defaults to `"fr"` (BAN is the French national register; the tier is FR-only today).
76
+ */
77
+ constructor(dbPath: string, opts: { streetLocale?: StreetLocale } = {}) {
78
+ this.#db = new DatabaseSync(dbPath, { readOnly: true })
79
+ this.#locale = opts.streetLocale ?? "fr"
80
+
81
+ // Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
82
+ // `street_centroid` table this lookup is a no-op miss, not a crash (mirrors the address-point reader).
83
+ if (hasTable(this.#db, "street_centroid")) {
84
+ this.#byPostcode = this.#db.prepare(
85
+ `SELECT ${AGG_SELECT} FROM street_centroid WHERE postcode = ? AND street_norm = ?`
86
+ )
87
+ this.#byLocality = this.#db.prepare(
88
+ `SELECT ${AGG_SELECT} FROM street_centroid WHERE locality_base = ? AND street_norm = ?`
89
+ )
90
+ }
91
+ }
92
+
93
+ find(query: { street: string; postcode?: string; locality?: string }): StreetCentroidHit | null {
94
+ if (!this.#byPostcode || !this.#byLocality) return null
95
+ const streetNorm = normalizeStreetForKeyLocale(query.street, this.#locale)
96
+
97
+ if (!streetNorm) return null
98
+
99
+ let row: AggRow | undefined
100
+
101
+ if (query.postcode?.trim()) {
102
+ row = this.#byPostcode.get(query.postcode.trim(), streetNorm) as AggRow | undefined
103
+ }
104
+
105
+ if ((!row || row.lat == null) && query.locality?.trim()) {
106
+ const base = stripArrondissement(normalizeLocalityForKey(query.locality))
107
+ row = this.#byLocality.get(base, streetNorm) as AggRow | undefined
108
+ }
109
+
110
+ if (!row || row.lat == null || row.lon == null) return null
111
+
112
+ return {
113
+ lat: row.lat,
114
+ lon: row.lon,
115
+ uncertaintyM: extentRadiusM(row.min_lat!, row.max_lat!, row.min_lon!, row.max_lon!),
116
+ source: row.source ?? "",
117
+ release: row.release ?? "",
118
+ }
119
+ }
120
+
121
+ close(): void {
122
+ this.#db.close()
123
+ }
124
+ }