@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Address-point interpolation — "Method 2" of the resolution ladder (#483, Phase 1 of
|
|
7
|
+
* `docs/articles/plan/2026-06-11-resolution-ladder.md`): when the exact address-point tier (#476)
|
|
8
|
+
* misses a house number, bracket the number with REAL neighbor points on the same street from the
|
|
9
|
+
* same #476 shard and interpolate linearly in house-number space between them. Real occupancy
|
|
10
|
+
* replaces TIGER's uniform-spacing assumption — the dominant error term of the TIGER pilot's gate
|
|
11
|
+
* miss; TIGER range interpolation (`StreetInterpolator`) demotes to the fallback for streets too
|
|
12
|
+
* sparse to bracket.
|
|
13
|
+
*
|
|
14
|
+
* Matching key is `street_key` — THE shared normalizer plus the route fold
|
|
15
|
+
* (`canonicalizeRouteKey`), identical at build time (`scripts/build-address-point-shard.ts`) and
|
|
16
|
+
* query time, by construction. Scope is postcode-first like the segment tier; a query without a
|
|
17
|
+
* postcode goes straight to the fallback (which carries its own statewide-ambiguity abstention).
|
|
18
|
+
*
|
|
19
|
+
* Bracketing contract:
|
|
20
|
+
*
|
|
21
|
+
* - Neighbor candidates NEVER include the queried number itself (any unit/duplicate row of it) — in
|
|
22
|
+
* production the exact tier would already have answered an on-file number, and in the eval
|
|
23
|
+
* this is what makes grading against the same shard non-circular by construction.
|
|
24
|
+
* - Both-sided bracket (`bracket: "both"`): linear interpolation between the nearest known number
|
|
25
|
+
* below and above; `uncertaintyM` = half the distance between them.
|
|
26
|
+
* - Single-sided (`bracket: "single"`): linear extrapolation along the two nearest known numbers on
|
|
27
|
+
* that side, capped at one pair-span beyond the nearest point (`t ≤ 2` — beyond that the line
|
|
28
|
+
* carries no evidence and the query falls through); `uncertaintyM` = the pair distance plus
|
|
29
|
+
* the extrapolated overshoot, explicitly larger than the both-sided radius.
|
|
30
|
+
* - No bracket (no neighbors, a single known number, or past the extrapolation cap): fall through to
|
|
31
|
+
* the TIGER fallback when configured, else null.
|
|
32
|
+
*
|
|
33
|
+
* Standalone like the segment tier — core wiring rides the Phase 2 ordered `spatialTiers` list.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { DatabaseSync } from "node:sqlite"
|
|
37
|
+
|
|
38
|
+
import type { InterpolationLookup } from "@mailwoman/resolver"
|
|
39
|
+
|
|
40
|
+
import { haversineKm } from "./geo.ts"
|
|
41
|
+
import type { InterpolatedHit, InterpolationQuery, StreetInterpolator } from "./interpolation.ts"
|
|
42
|
+
import { hasTable } from "./sqlite-utils.ts"
|
|
43
|
+
import { canonicalizeRouteKey, normalizeStreetForKey } from "./street-normalize.ts"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extrapolation cap for a single-sided bracket: at most one pair-span beyond the nearest known point (`t = 2`). Past
|
|
47
|
+
* it, the two-point line carries no evidence about the query number.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_EXTRAPOLATION_T = 2
|
|
50
|
+
|
|
51
|
+
interface PointRow {
|
|
52
|
+
n: number
|
|
53
|
+
lat: number
|
|
54
|
+
lon: number
|
|
55
|
+
source: string
|
|
56
|
+
release: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One known house number on the street: the centroid of its rows (unit siblings collapse). */
|
|
60
|
+
interface NumberAnchor {
|
|
61
|
+
n: number
|
|
62
|
+
lat: number
|
|
63
|
+
lon: number
|
|
64
|
+
source: string
|
|
65
|
+
release: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class AddressPointInterpolator implements InterpolationLookup {
|
|
69
|
+
readonly #db: DatabaseSync
|
|
70
|
+
readonly #ownsDB: boolean
|
|
71
|
+
readonly #fallback: StreetInterpolator | undefined
|
|
72
|
+
readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
|
|
73
|
+
|
|
74
|
+
constructor(opts: { dbPath?: string; database?: DatabaseSync; fallback?: StreetInterpolator }) {
|
|
75
|
+
if (opts.database) {
|
|
76
|
+
this.#db = opts.database
|
|
77
|
+
this.#ownsDB = false
|
|
78
|
+
} else if (opts.dbPath) {
|
|
79
|
+
this.#db = new DatabaseSync(opts.dbPath, { readOnly: true })
|
|
80
|
+
this.#ownsDB = true
|
|
81
|
+
} else {
|
|
82
|
+
throw new Error("AddressPointInterpolator: one of dbPath or database is required")
|
|
83
|
+
}
|
|
84
|
+
this.#fallback = opts.fallback
|
|
85
|
+
|
|
86
|
+
// Degrade gracefully on an empty/tableless shard (#568): with no `address_point` table this tier
|
|
87
|
+
// is skipped, deferring to the segment fallback rather than crashing at construction.
|
|
88
|
+
if (hasTable(this.#db, "address_point")) {
|
|
89
|
+
// Strictly-numeric neighbor numbers on the route-folded street key within the ZIP. The
|
|
90
|
+
// queried number itself is excluded HERE (see module doc: non-circular by construction).
|
|
91
|
+
this.#byPostcode = this.#db.prepare(
|
|
92
|
+
`SELECT CAST(number AS INTEGER) AS n, lat, lon, source, release
|
|
93
|
+
FROM address_point
|
|
94
|
+
WHERE postcode = ? AND street_key = ?
|
|
95
|
+
AND number GLOB '[0-9]*' AND number NOT GLOB '*[^0-9]*'
|
|
96
|
+
AND CAST(number AS INTEGER) != ?`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
find(query: InterpolationQuery): InterpolatedHit | null {
|
|
102
|
+
const streetKey = canonicalizeRouteKey(normalizeStreetForKey(query.street))
|
|
103
|
+
const numberRaw = query.number.trim()
|
|
104
|
+
|
|
105
|
+
if (!streetKey || !/^\d+$/.test(numberRaw)) return null
|
|
106
|
+
const n = Number(numberRaw)
|
|
107
|
+
|
|
108
|
+
// No own table (empty shard) or no postcode → defer to the segment fallback rather than query.
|
|
109
|
+
if (!this.#byPostcode || !query.postcode) return this.#fallback?.find(query) ?? null
|
|
110
|
+
|
|
111
|
+
const rows = this.#byPostcode.all(query.postcode.trim(), streetKey, n) as unknown as PointRow[]
|
|
112
|
+
const hit = rows.length >= 2 ? interpolateFromNeighbors(rows, n) : null
|
|
113
|
+
|
|
114
|
+
return hit ?? this.#fallback?.find(query) ?? null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
close(): void {
|
|
118
|
+
if (this.#ownsDB) {
|
|
119
|
+
this.#db.close()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Collapse rows to one centroid anchor per distinct house number, sorted ascending. */
|
|
125
|
+
function anchorsByNumber(rows: readonly PointRow[]): NumberAnchor[] {
|
|
126
|
+
const byN = new Map<number, PointRow[]>()
|
|
127
|
+
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const group = byN.get(row.n)
|
|
130
|
+
|
|
131
|
+
if (group) {
|
|
132
|
+
group.push(row)
|
|
133
|
+
} else {
|
|
134
|
+
byN.set(row.n, [row])
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [...byN.entries()]
|
|
139
|
+
.map(([n, group]) => ({
|
|
140
|
+
n,
|
|
141
|
+
lat: group.reduce((sum, r) => sum + r.lat, 0) / group.length,
|
|
142
|
+
lon: group.reduce((sum, r) => sum + r.lon, 0) / group.length,
|
|
143
|
+
source: group[0]!.source,
|
|
144
|
+
release: group[0]!.release,
|
|
145
|
+
}))
|
|
146
|
+
.sort((a, b) => a.n - b.n)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function interpolateFromNeighbors(rows: readonly PointRow[], n: number): InterpolatedHit | null {
|
|
150
|
+
const anchors = anchorsByNumber(rows)
|
|
151
|
+
|
|
152
|
+
// Nearest known number below and above the query (the rows never contain n itself).
|
|
153
|
+
let below: NumberAnchor | undefined
|
|
154
|
+
let above: NumberAnchor | undefined
|
|
155
|
+
|
|
156
|
+
for (const anchor of anchors) {
|
|
157
|
+
if (anchor.n < n) {
|
|
158
|
+
below = anchor
|
|
159
|
+
} else {
|
|
160
|
+
above = anchor
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (below && above) {
|
|
166
|
+
const t = (n - below.n) / (above.n - below.n)
|
|
167
|
+
const spanM = haversineKm(below.lat, below.lon, above.lat, above.lon) * 1000
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
lat: below.lat + (above.lat - below.lat) * t,
|
|
171
|
+
lon: below.lon + (above.lon - below.lon) * t,
|
|
172
|
+
interpolated: true,
|
|
173
|
+
method: "address_point",
|
|
174
|
+
bracket: "both",
|
|
175
|
+
uncertaintyM: Math.round(spanM / 2),
|
|
176
|
+
source: below.source,
|
|
177
|
+
release: below.release,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Single-sided: extrapolate along the two nearest known numbers on the populated side.
|
|
182
|
+
// `near` is the anchor closest to n, `far` the next one out; t > 1 by construction.
|
|
183
|
+
const side = below ? anchors.slice(-2) : anchors.slice(0, 2)
|
|
184
|
+
|
|
185
|
+
if (side.length < 2) return null
|
|
186
|
+
const [far, near] = below ? [side[0]!, side[1]!] : [side[1]!, side[0]!]
|
|
187
|
+
const t = (n - far.n) / (near.n - far.n)
|
|
188
|
+
|
|
189
|
+
if (t > MAX_EXTRAPOLATION_T) return null
|
|
190
|
+
|
|
191
|
+
const lat = far.lat + (near.lat - far.lat) * t
|
|
192
|
+
const lon = far.lon + (near.lon - far.lon) * t
|
|
193
|
+
const pairM = haversineKm(near.lat, near.lon, far.lat, far.lon) * 1000
|
|
194
|
+
const overshootM = haversineKm(lat, lon, near.lat, near.lon) * 1000
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
lat,
|
|
198
|
+
lon,
|
|
199
|
+
interpolated: true,
|
|
200
|
+
method: "address_point",
|
|
201
|
+
bracket: "single",
|
|
202
|
+
// Explicitly larger than the both-sided radius: the whole pair span plus the overshoot.
|
|
203
|
+
uncertaintyM: Math.round(pairM + overshootM),
|
|
204
|
+
source: near.source,
|
|
205
|
+
release: near.release,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed schema for the SITUS / rooftop ADDRESS-POINT shards (`address-points-<cc>-<slug>.db`, built
|
|
7
|
+
* by `scripts/build-address-point-shard.ts` — the #476/#567 national rooftop tier behind the
|
|
8
|
+
* demo's "type any US address, get the building"). Single source of truth for the columns shared
|
|
9
|
+
* by the BUILDER and the READER ({@link AddressPointSqliteLookup}), so a column rename in one is a
|
|
10
|
+
* compile error in the other.
|
|
11
|
+
*
|
|
12
|
+
* The builder's hot INSERT (tens of millions of rows per state) stays a POSITIONAL prepared
|
|
13
|
+
* statement for throughput — but its column list is derived from {@link ADDRESS_POINT_COLUMNS}
|
|
14
|
+
* here, and its table comes from {@link createAddressPointTable}, so the positional order can't
|
|
15
|
+
* silently drift from what the reader expects. (Same convention as the candidate build: typed
|
|
16
|
+
* schema guards the contract; positional inserts keep the speed.)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Kysely } from "kysely"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One rooftop address point. `(street_norm, number)` within a `postcode` (preferred) or `locality_norm` scope is the
|
|
23
|
+
* lookup; `street_key` is the #483 route-fold key for interpolation. Coordinates are non-null (the builder drops
|
|
24
|
+
* non-finite coords). `unit`/`postcode`/`locality_norm` are nullable (not every source carries all three).
|
|
25
|
+
*/
|
|
26
|
+
export interface AddressPointTable {
|
|
27
|
+
/** Shared {@link normalizeStreetForKey} of the street — the build/query-consistent probe key. */
|
|
28
|
+
street_norm: string
|
|
29
|
+
/** `canonicalizeRouteKey(street_norm)` — the route-fold key (#483 Method 2). */
|
|
30
|
+
street_key: string
|
|
31
|
+
/** House number, normalized lower-case (kept TEXT — "123-A", "12 1/2" must survive). */
|
|
32
|
+
number: string
|
|
33
|
+
unit: string | null
|
|
34
|
+
postcode: string | null
|
|
35
|
+
/** Shared {@link normalizeLocalityForKey} of the locality — the fallback scope. */
|
|
36
|
+
locality_norm: string | null
|
|
37
|
+
/** The street as it appeared in the source (kept for display / debugging). */
|
|
38
|
+
street_raw: string
|
|
39
|
+
lat: number
|
|
40
|
+
lon: number
|
|
41
|
+
/** Provenance: the dataset this point came from (e.g. `overture:us`, `openaddresses`). */
|
|
42
|
+
source: string
|
|
43
|
+
/** The pinned data release the point was ingested from. */
|
|
44
|
+
release: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The address-point database schema for `new DatabaseClient<AddressPointDatabase>(...)`. */
|
|
48
|
+
export interface AddressPointDatabase {
|
|
49
|
+
address_point: AddressPointTable
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `address_point` columns in INSERT order. The builder's positional prepared statement derives its placeholder list
|
|
54
|
+
* from this, so the positional order can't drift from the DDL / the reader.
|
|
55
|
+
*/
|
|
56
|
+
export const ADDRESS_POINT_COLUMNS = [
|
|
57
|
+
"street_norm",
|
|
58
|
+
"street_key",
|
|
59
|
+
"number",
|
|
60
|
+
"unit",
|
|
61
|
+
"postcode",
|
|
62
|
+
"locality_norm",
|
|
63
|
+
"street_raw",
|
|
64
|
+
"lat",
|
|
65
|
+
"lon",
|
|
66
|
+
"source",
|
|
67
|
+
"release",
|
|
68
|
+
] as const
|
|
69
|
+
|
|
70
|
+
/** Create the `address_point` table — called before the streaming bulk load. */
|
|
71
|
+
export async function createAddressPointTable(db: Kysely<AddressPointDatabase>): Promise<void> {
|
|
72
|
+
await db.schema
|
|
73
|
+
.createTable("address_point")
|
|
74
|
+
.addColumn("street_norm", "text", (c) => c.notNull())
|
|
75
|
+
// `street_key` = canonicalizeRouteKey(street_norm): the route-fold key (#483 Method 2).
|
|
76
|
+
.addColumn("street_key", "text", (c) => c.notNull())
|
|
77
|
+
.addColumn("number", "text", (c) => c.notNull())
|
|
78
|
+
.addColumn("unit", "text")
|
|
79
|
+
.addColumn("postcode", "text")
|
|
80
|
+
.addColumn("locality_norm", "text")
|
|
81
|
+
.addColumn("street_raw", "text", (c) => c.notNull())
|
|
82
|
+
.addColumn("lat", "real", (c) => c.notNull())
|
|
83
|
+
.addColumn("lon", "real", (c) => c.notNull())
|
|
84
|
+
.addColumn("source", "text", (c) => c.notNull())
|
|
85
|
+
.addColumn("release", "text", (c) => c.notNull())
|
|
86
|
+
.execute()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Create the three probe indexes the reader relies on (postcode-scope, locality-scope, route-key). */
|
|
90
|
+
export async function createAddressPointIndexes(db: Kysely<AddressPointDatabase>): Promise<void> {
|
|
91
|
+
await db.schema
|
|
92
|
+
.createIndex("idx_ap_postcode")
|
|
93
|
+
.on("address_point")
|
|
94
|
+
.columns(["postcode", "street_norm", "number"])
|
|
95
|
+
.execute()
|
|
96
|
+
await db.schema
|
|
97
|
+
.createIndex("idx_ap_locality")
|
|
98
|
+
.on("address_point")
|
|
99
|
+
.columns(["locality_norm", "street_norm", "number"])
|
|
100
|
+
.execute()
|
|
101
|
+
await db.schema.createIndex("idx_ap_streetkey").on("address_point").columns(["postcode", "street_key"]).execute()
|
|
102
|
+
// Street-first index for the BBOX scope (#247): OSM points often carry no postcode/locality, so the
|
|
103
|
+
// reader scopes a `(street_norm, number)` probe by the resolved locality's bbox (lat/lon BETWEEN). The
|
|
104
|
+
// postcode/locality indexes lead with their scope column and can't serve this; US situs never probes by
|
|
105
|
+
// bbox so it simply carries one extra (cheap) index on a future rebuild.
|
|
106
|
+
await db.schema.createIndex("idx_ap_street").on("address_point").columns(["street_norm", "number"]).execute()
|
|
107
|
+
}
|
package/address-point.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* SQLite implementation of core's `AddressPointLookup` (#476): exact `(street, number)` within a
|
|
7
|
+
* postcode (preferred), locality, or — for shards whose points carry no scope tag (OSM, #247) —
|
|
8
|
+
* the resolved locality's BBOX. Query-side normalization is THE shared normalizer
|
|
9
|
+
* (`street-normalize.ts`), selected per the shard's `streetLocale` so build-side and probe-side
|
|
10
|
+
* stay identical by construction (US delegates to the USPS pipeline; FR/DE/NL use the locale rules).
|
|
11
|
+
*
|
|
12
|
+
* Matching is exact-after-normalization only — no fuzzy street matching in this tier (measure how
|
|
13
|
+
* far exact gets first; fuzz is a later, separate decision). Scope order is most-selective first:
|
|
14
|
+
* postcode, then locality, then the bbox fall-through (only when a bbox is supplied AND the prior
|
|
15
|
+
* scopes missed). Multiple hits return the first by rowid — unit siblings share the building coord.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DatabaseSync } from "node:sqlite"
|
|
19
|
+
|
|
20
|
+
import type { AddressPointHit, AddressPointLookup } from "@mailwoman/resolver"
|
|
21
|
+
|
|
22
|
+
import type { AddressPointTable } from "./address-point-schema.ts"
|
|
23
|
+
import { hasTable } from "./sqlite-utils.ts"
|
|
24
|
+
import {
|
|
25
|
+
normalizeLocalityForKey,
|
|
26
|
+
normalizeStreetForKeyLocale,
|
|
27
|
+
stripArrondissement,
|
|
28
|
+
type StreetLocale,
|
|
29
|
+
} from "./street-normalize.ts"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The columns this lookup projects — a typed slice of the SHARED {@link AddressPointTable}, so a column rename in
|
|
33
|
+
* `build-address-point-shard.ts` (the writer) is a compile error here (the reader).
|
|
34
|
+
*/
|
|
35
|
+
type AddressPointRow = Pick<AddressPointTable, "lat" | "lon" | "source" | "release">
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The 4 columns the reader SELECTs, in the schema's order — referenced by the prepared SELECTs so the projected
|
|
39
|
+
* `AddressPointRow` stays in lockstep with the shared schema.
|
|
40
|
+
*/
|
|
41
|
+
const SELECT_COLS = "lat, lon, source, release"
|
|
42
|
+
|
|
43
|
+
export class AddressPointSqliteLookup implements AddressPointLookup {
|
|
44
|
+
readonly #db: DatabaseSync
|
|
45
|
+
readonly #locale: StreetLocale
|
|
46
|
+
readonly #byPostcode: ReturnType<DatabaseSync["prepare"]> | undefined
|
|
47
|
+
readonly #byLocality: ReturnType<DatabaseSync["prepare"]> | undefined
|
|
48
|
+
readonly #byBbox: ReturnType<DatabaseSync["prepare"]> | undefined
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param dbPath Shard path.
|
|
52
|
+
* @param opts.streetLocale The street-normalization locale this shard was BUILT with — must match, or every key
|
|
53
|
+
* misses. Defaults to `"us"` (the situs tier), so existing callers are unchanged.
|
|
54
|
+
*/
|
|
55
|
+
constructor(dbPath: string, opts: { streetLocale?: StreetLocale } = {}) {
|
|
56
|
+
this.#db = new DatabaseSync(dbPath, { readOnly: true })
|
|
57
|
+
this.#locale = opts.streetLocale ?? "us"
|
|
58
|
+
|
|
59
|
+
// Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
|
|
60
|
+
// `address_point` table this lookup is a no-op miss, not a crash that loses the whole state (#568).
|
|
61
|
+
if (hasTable(this.#db, "address_point")) {
|
|
62
|
+
this.#byPostcode = this.#db.prepare(
|
|
63
|
+
`SELECT ${SELECT_COLS} FROM address_point
|
|
64
|
+
WHERE postcode = ? AND street_norm = ? AND number = ? LIMIT 1`
|
|
65
|
+
)
|
|
66
|
+
this.#byLocality = this.#db.prepare(
|
|
67
|
+
`SELECT ${SELECT_COLS} FROM address_point
|
|
68
|
+
WHERE locality_norm = ? AND street_norm = ? AND number = ? LIMIT 1`
|
|
69
|
+
)
|
|
70
|
+
this.#byBbox = this.#db.prepare(
|
|
71
|
+
`SELECT ${SELECT_COLS} FROM address_point
|
|
72
|
+
WHERE street_norm = ? AND number = ? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ? LIMIT 1`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
find(query: {
|
|
78
|
+
street: string
|
|
79
|
+
number: string
|
|
80
|
+
postcode?: string
|
|
81
|
+
locality?: string
|
|
82
|
+
bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
|
|
83
|
+
}): AddressPointHit | null {
|
|
84
|
+
if (!this.#byPostcode || !this.#byLocality || !this.#byBbox) return null
|
|
85
|
+
const streetNorm = normalizeStreetForKeyLocale(query.street, this.#locale)
|
|
86
|
+
const number = query.number.trim().toLowerCase()
|
|
87
|
+
|
|
88
|
+
if (!streetNorm || !number) return null
|
|
89
|
+
|
|
90
|
+
let row: AddressPointRow | undefined
|
|
91
|
+
|
|
92
|
+
if (query.postcode) {
|
|
93
|
+
row = this.#byPostcode.get(query.postcode.trim(), streetNorm, number) as AddressPointRow | undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!row && query.locality) {
|
|
97
|
+
// FR shards key arrondissement communes at the base city (both-sides fold, see the BAN
|
|
98
|
+
// builder + stripArrondissement) — fold the probe too so "Paris 13e Arrondissement" and
|
|
99
|
+
// "Paris" both hit. No-op for "us" shards and every non-arrondissement commune.
|
|
100
|
+
const localityKey =
|
|
101
|
+
this.#locale === "fr"
|
|
102
|
+
? stripArrondissement(normalizeLocalityForKey(query.locality))
|
|
103
|
+
: normalizeLocalityForKey(query.locality)
|
|
104
|
+
row = this.#byLocality.get(localityKey, streetNorm, number) as AddressPointRow | undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Bbox fall-through (#247): the point carries no postcode/locality of its own, but its coordinate falls
|
|
108
|
+
// inside the resolved locality's box. Only reached when the scoped probes missed AND a bbox was supplied.
|
|
109
|
+
if (!row && query.bbox) {
|
|
110
|
+
const b = query.bbox
|
|
111
|
+
row = this.#byBbox.get(streetNorm, number, b.minLat, b.maxLat, b.minLon, b.maxLon) as AddressPointRow | undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!row) return null
|
|
115
|
+
|
|
116
|
+
return { lat: row.lat, lon: row.lon, source: row.source, release: row.release }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close(): void {
|
|
120
|
+
this.#db.close()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Repair the only-self ancestry that {@link populateAncestors} (the parent_id closure in
|
|
7
|
+
* unified-schema.ts) leaves for places whose `wof:parent_id` is the WOF `-4` "ambiguous /
|
|
8
|
+
* multi-parent" sentinel.
|
|
9
|
+
*
|
|
10
|
+
* Root cause (#440 / #832): a place that straddles multiple parents — New York City spans five
|
|
11
|
+
* counties (its boroughs), London 30+ — carries `wof:parent_id = -4`, so the parent_id closure
|
|
12
|
+
* dead-ends and the place gets NO region/county/country ancestry. The resolver's region-descendant
|
|
13
|
+
* filter then can't reach it: given "New York, NY", NYC (with no NY-state ancestor) is excluded and
|
|
14
|
+
* a correctly-parented namesake ("New York Mills", pop 3,190) wins over NYC's 8.8M. The same defect
|
|
15
|
+
* orphans London, Singapore, and ~2,850 other localities — the most demo-visible queries.
|
|
16
|
+
*
|
|
17
|
+
* The authoritative hierarchy IS in the source geojson: `wof:hierarchy` is an array of branches,
|
|
18
|
+
* each a `<placetype>_id` → id map (region_id, county_id, country_id, …), fully populated even when
|
|
19
|
+
* parent_id is -4. This reads it for every only-self place and inserts the missing ancestor rows
|
|
20
|
+
* (one per distinct ancestor across branches).
|
|
21
|
+
*
|
|
22
|
+
* MUST run AFTER populateAncestors and BEFORE the build freezes (VACUUM INTO), so the rows land in
|
|
23
|
+
* the shipped artifact — `scripts/build-unified-wof.ts` Phase 3 calls it inline. The standalone
|
|
24
|
+
* `scripts/backfill-ancestors-from-hierarchy.ts` is a thin CLI over the same function for ad-hoc
|
|
25
|
+
* repair of an already-built DB. Idempotent: only touches places with <= 1 ancestor row (self), and
|
|
26
|
+
* inserts each (id, ancestor_id) at most once.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
30
|
+
import { join } from "node:path"
|
|
31
|
+
import type { DatabaseSync } from "node:sqlite"
|
|
32
|
+
|
|
33
|
+
/** Genuinely top-level placetypes — they never have (or need) an ancestor, so skip them. */
|
|
34
|
+
const TOP_PLACETYPES = new Set(["country", "continent", "empire", "ocean", "marinearea", "planet"])
|
|
35
|
+
|
|
36
|
+
export interface AncestryBackfillResult {
|
|
37
|
+
/** Places that gained at least one ancestor row. */
|
|
38
|
+
placesFixed: number
|
|
39
|
+
/** Total ancestor rows inserted. */
|
|
40
|
+
rowsAdded: number
|
|
41
|
+
/**
|
|
42
|
+
* Only-self candidates whose source geojson could not be found (non-WOF backfilled places, or repos not present
|
|
43
|
+
* locally) — skipped, not an error.
|
|
44
|
+
*/
|
|
45
|
+
noGeojson: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Discover the `data` directories under a WOF repos root that hold sharded geojson, e.g.
|
|
50
|
+
* `<root>/whosonfirst-data/whosonfirst-data-admin-us/data`. Resolves an id to its geojson via these roots. Accepts both
|
|
51
|
+
* the nested lab layout (a `whosonfirst-data` group dir holding the admin repos) and a flat layout (admin repos
|
|
52
|
+
* directly under the root); searches at most two directory levels deep.
|
|
53
|
+
*/
|
|
54
|
+
export function discoverAdminDataRoots(reposRoot: string): string[] {
|
|
55
|
+
const roots: string[] = []
|
|
56
|
+
|
|
57
|
+
const visit = (dir: string, depth: number): void => {
|
|
58
|
+
if (depth > 2) return
|
|
59
|
+
|
|
60
|
+
let names: string[]
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(dir, { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory())
|
|
65
|
+
.map((e) => e.name)
|
|
66
|
+
} catch {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const child = join(dir, name)
|
|
72
|
+
|
|
73
|
+
if (name === "data") {
|
|
74
|
+
roots.push(child)
|
|
75
|
+
} else if (name.startsWith("whosonfirst-data")) {
|
|
76
|
+
visit(child, depth + 1)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
visit(reposRoot, 0)
|
|
82
|
+
|
|
83
|
+
return roots
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** WOF geojson lives sharded: an id resolves to `<3-char chunks>/<id>.geojson` under each data root. */
|
|
87
|
+
function geojsonForID(id: number, roots: readonly string[]): Record<string, unknown> | null {
|
|
88
|
+
const s = String(id)
|
|
89
|
+
const chunks: string[] = []
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < s.length; i += 3) {
|
|
92
|
+
chunks.push(s.slice(i, i + 3))
|
|
93
|
+
}
|
|
94
|
+
const rel = join(chunks.join("/"), `${s}.geojson`)
|
|
95
|
+
|
|
96
|
+
for (const root of roots) {
|
|
97
|
+
const fp = join(root, rel)
|
|
98
|
+
|
|
99
|
+
if (existsSync(fp)) {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(readFileSync(fp, "utf8")) as Record<string, unknown>
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// `<placetype>_id` key → ancestor placetype. WOF hierarchy keys are e.g. region_id, county_id. Self
|
|
112
|
+
// is filtered downstream by the `aid === id` check, so we do NOT special-case locality here: for a
|
|
113
|
+
// locality candidate `locality_id` IS self (dropped by aid===id), but for a neighbourhood candidate
|
|
114
|
+
// `locality_id` is its PARENT locality — a real ancestor we must keep.
|
|
115
|
+
function placetypeFromKey(key: string): string | null {
|
|
116
|
+
if (!key.endsWith("_id")) return null
|
|
117
|
+
|
|
118
|
+
return key.slice(0, -3)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Insert missing ancestor rows for only-self places by reading `wof:hierarchy` from their source geojson under
|
|
123
|
+
* `geojsonRoots` (see {@link discoverAdminDataRoots}). Runs inside a single transaction; caller owns connection
|
|
124
|
+
* lifecycle (open, WAL checkpoint, close).
|
|
125
|
+
*
|
|
126
|
+
* `opts.maxId` bounds the candidate scan to ids BELOW it — pass the synthetic-id base (`OVERTURE_ID_BASE`, 8e12) so the
|
|
127
|
+
* backfill considers only real WOF places. Overture/GeoNames rows carry synthetic ids and have NO `wof:hierarchy`
|
|
128
|
+
* geojson, so probing them is pure waste: on a wide-coverage DB the only-self set is millions of Overture/GeoNames leaf
|
|
129
|
+
* localities, and the per-candidate geojson probe across every repo root turns a seconds-long WOF-only pass into a
|
|
130
|
+
* ~40-minute one (their ancestry comes from the parent_id closure, not this backfill). Correctness-preserving — the
|
|
131
|
+
* skipped rows would have `noGeojson`-skipped anyway. Omit `maxId` (default) for the legacy WOF-only DBs.
|
|
132
|
+
*/
|
|
133
|
+
export function backfillAncestorsFromHierarchy(
|
|
134
|
+
db: DatabaseSync,
|
|
135
|
+
geojsonRoots: readonly string[],
|
|
136
|
+
opts: { maxId?: number } = {}
|
|
137
|
+
): AncestryBackfillResult {
|
|
138
|
+
const maxId = opts.maxId ?? Number.MAX_SAFE_INTEGER
|
|
139
|
+
// `s.id < ?` first lets SQLite prune by the PK index before the correlated only-self subquery runs at all.
|
|
140
|
+
const candidates = db
|
|
141
|
+
.prepare(
|
|
142
|
+
`SELECT s.id AS id, s.placetype AS placetype FROM spr s
|
|
143
|
+
WHERE s.id < ? AND (SELECT count(*) FROM ancestors a WHERE a.id = s.id) <= 1`
|
|
144
|
+
)
|
|
145
|
+
.all(maxId) as Array<{ id: number; placetype: string }>
|
|
146
|
+
|
|
147
|
+
const insert = db.prepare(
|
|
148
|
+
"INSERT INTO ancestors (id, ancestor_id, ancestor_placetype, lastmodified) VALUES (?, ?, ?, 0)"
|
|
149
|
+
)
|
|
150
|
+
const hasRow = db.prepare("SELECT 1 FROM ancestors WHERE id = ? AND ancestor_id = ? LIMIT 1")
|
|
151
|
+
|
|
152
|
+
let placesFixed = 0
|
|
153
|
+
let rowsAdded = 0
|
|
154
|
+
let noGeojson = 0
|
|
155
|
+
db.exec("BEGIN")
|
|
156
|
+
|
|
157
|
+
for (const { id, placetype } of candidates) {
|
|
158
|
+
if (TOP_PLACETYPES.has(placetype)) continue
|
|
159
|
+
const gj = geojsonForID(id, geojsonRoots)
|
|
160
|
+
const props = (gj?.["properties"] ?? null) as Record<string, unknown> | null
|
|
161
|
+
const hierarchy = (props?.["wof:hierarchy"] ?? null) as Array<Record<string, number>> | null
|
|
162
|
+
|
|
163
|
+
if (!hierarchy || hierarchy.length === 0) {
|
|
164
|
+
if (!gj) {
|
|
165
|
+
noGeojson++
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Collect distinct (ancestor_id, placetype) across all hierarchy branches, excluding self.
|
|
171
|
+
const seen = new Map<number, string>()
|
|
172
|
+
|
|
173
|
+
for (const branch of hierarchy) {
|
|
174
|
+
for (const [key, val] of Object.entries(branch)) {
|
|
175
|
+
const pt = placetypeFromKey(key)
|
|
176
|
+
|
|
177
|
+
if (!pt) continue
|
|
178
|
+
const aid = Number(val)
|
|
179
|
+
|
|
180
|
+
if (!Number.isFinite(aid) || aid <= 0 || aid === id) continue
|
|
181
|
+
|
|
182
|
+
if (!seen.has(aid)) {
|
|
183
|
+
seen.set(aid, pt)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let added = 0
|
|
189
|
+
|
|
190
|
+
for (const [aid, pt] of seen) {
|
|
191
|
+
if (hasRow.get(id, aid)) continue
|
|
192
|
+
insert.run(id, aid, pt)
|
|
193
|
+
added++
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (added > 0) {
|
|
197
|
+
placesFixed++
|
|
198
|
+
rowsAdded += added
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
db.exec("COMMIT")
|
|
203
|
+
|
|
204
|
+
return { placesFixed, rowsAdded, noGeojson }
|
|
205
|
+
}
|