@mailwoman/resolver-wof-sqlite 9.0.0 → 9.2.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/README.md +28 -9
- package/address-point-interpolation.ts +18 -8
- package/address-point-schema.ts +18 -6
- package/address-point.ts +111 -18
- package/ancestry.ts +9 -6
- package/build-candidate.ts +287 -157
- package/build-slim.ts +3 -3
- package/candidate/alias-bags.ts +54 -0
- package/candidate/ancestors-sidecar.ts +206 -0
- package/candidate/country-display-names.ts +79 -0
- package/candidate/name-roles.ts +237 -0
- package/candidate/own-name.ts +146 -0
- package/candidate/place-attrs.ts +44 -0
- package/candidate/shard-fold.ts +137 -0
- package/candidate-ancestors-schema.ts +195 -0
- package/candidate-fts.ts +4 -2
- package/candidate-importance.ts +228 -0
- package/candidate-lookup.ts +564 -174
- package/candidate-schema.ts +60 -3
- package/candidate-scoring.ts +268 -0
- package/capital-schema.ts +90 -0
- package/capitals.ts +148 -0
- package/coincident-roles.ts +69 -10
- package/convention-schema.ts +72 -0
- package/convention.ts +2 -2
- package/coverage-manifest-schema.ts +7 -7
- package/currency-backfill.ts +249 -0
- package/exact-match.ts +104 -0
- package/fst-autocomplete.ts +105 -122
- package/fst-builder.ts +39 -47
- package/fst-deserialize-web.ts +43 -7
- package/fst-freshness.ts +2 -2
- package/fst-serialize.ts +68 -12
- package/fst-types.ts +35 -1
- package/fts-query.ts +1 -1
- package/fts.ts +16 -4
- package/geonames-postal.ts +2 -2
- package/index.ts +26 -14
- package/interpolation.ts +113 -19
- package/lookup.ts +118 -560
- package/name-score.ts +6 -4
- package/out/address-point-interpolation.d.ts.map +1 -1
- package/out/address-point-interpolation.js +13 -7
- package/out/address-point-interpolation.js.map +1 -1
- package/out/address-point-schema.d.ts +16 -6
- package/out/address-point-schema.d.ts.map +1 -1
- package/out/address-point-schema.js.map +1 -1
- package/out/address-point.d.ts.map +1 -1
- package/out/address-point.js +70 -14
- package/out/address-point.js.map +1 -1
- package/out/ancestry.d.ts +2 -2
- package/out/ancestry.d.ts.map +1 -1
- package/out/ancestry.js +5 -6
- package/out/ancestry.js.map +1 -1
- package/out/build-candidate.d.ts +108 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +151 -120
- package/out/build-candidate.js.map +1 -1
- package/out/build-slim.d.ts +1 -1
- package/out/build-slim.js +3 -3
- package/out/build-slim.js.map +1 -1
- package/out/candidate/alias-bags.d.ts +17 -0
- package/out/candidate/alias-bags.d.ts.map +1 -0
- package/out/candidate/alias-bags.js +39 -0
- package/out/candidate/alias-bags.js.map +1 -0
- package/out/candidate/ancestors-sidecar.d.ts +33 -0
- package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
- package/out/candidate/ancestors-sidecar.js +140 -0
- package/out/candidate/ancestors-sidecar.js.map +1 -0
- package/out/candidate/country-display-names.d.ts +35 -0
- package/out/candidate/country-display-names.d.ts.map +1 -0
- package/out/candidate/country-display-names.js +59 -0
- package/out/candidate/country-display-names.js.map +1 -0
- package/out/candidate/name-roles.d.ts +55 -0
- package/out/candidate/name-roles.d.ts.map +1 -0
- package/out/candidate/name-roles.js +165 -0
- package/out/candidate/name-roles.js.map +1 -0
- package/out/candidate/own-name.d.ts +50 -0
- package/out/candidate/own-name.d.ts.map +1 -0
- package/out/candidate/own-name.js +132 -0
- package/out/candidate/own-name.js.map +1 -0
- package/out/candidate/place-attrs.d.ts +43 -0
- package/out/candidate/place-attrs.d.ts.map +1 -0
- package/out/candidate/place-attrs.js +15 -0
- package/out/candidate/place-attrs.js.map +1 -0
- package/out/candidate/shard-fold.d.ts +31 -0
- package/out/candidate/shard-fold.d.ts.map +1 -0
- package/out/candidate/shard-fold.js +104 -0
- package/out/candidate/shard-fold.js.map +1 -0
- package/out/candidate-ancestors-schema.d.ts +150 -0
- package/out/candidate-ancestors-schema.d.ts.map +1 -0
- package/out/candidate-ancestors-schema.js +123 -0
- package/out/candidate-ancestors-schema.js.map +1 -0
- package/out/candidate-fts.d.ts +4 -2
- package/out/candidate-fts.d.ts.map +1 -1
- package/out/candidate-fts.js +4 -2
- package/out/candidate-fts.js.map +1 -1
- package/out/candidate-importance.d.ts +132 -0
- package/out/candidate-importance.d.ts.map +1 -0
- package/out/candidate-importance.js +174 -0
- package/out/candidate-importance.js.map +1 -0
- package/out/candidate-lookup.d.ts +22 -37
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +446 -132
- package/out/candidate-lookup.js.map +1 -1
- package/out/candidate-schema.d.ts +52 -4
- package/out/candidate-schema.d.ts.map +1 -1
- package/out/candidate-schema.js +8 -0
- package/out/candidate-schema.js.map +1 -1
- package/out/candidate-scoring.d.ts +34 -0
- package/out/candidate-scoring.d.ts.map +1 -0
- package/out/candidate-scoring.js +200 -0
- package/out/candidate-scoring.js.map +1 -0
- package/out/capital-schema.d.ts +51 -0
- package/out/capital-schema.d.ts.map +1 -0
- package/out/capital-schema.js +63 -0
- package/out/capital-schema.js.map +1 -0
- package/out/capitals.d.ts +69 -0
- package/out/capitals.d.ts.map +1 -0
- package/out/capitals.js +98 -0
- package/out/capitals.js.map +1 -0
- package/out/coincident-roles.d.ts +7 -0
- package/out/coincident-roles.d.ts.map +1 -1
- package/out/coincident-roles.js +42 -8
- package/out/coincident-roles.js.map +1 -1
- package/out/convention-schema.d.ts +51 -0
- package/out/convention-schema.d.ts.map +1 -0
- package/out/convention-schema.js +34 -0
- package/out/convention-schema.js.map +1 -0
- package/out/convention.d.ts +1 -1
- package/out/convention.js +2 -2
- package/out/coverage-manifest-schema.js +3 -7
- package/out/coverage-manifest-schema.js.map +1 -1
- package/out/currency-backfill.d.ts +46 -0
- package/out/currency-backfill.d.ts.map +1 -0
- package/out/currency-backfill.js +180 -0
- package/out/currency-backfill.js.map +1 -0
- package/out/exact-match.d.ts +25 -0
- package/out/exact-match.d.ts.map +1 -0
- package/out/exact-match.js +89 -0
- package/out/exact-match.js.map +1 -0
- package/out/fst-autocomplete.d.ts +24 -14
- package/out/fst-autocomplete.d.ts.map +1 -1
- package/out/fst-autocomplete.js +84 -100
- package/out/fst-autocomplete.js.map +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +32 -40
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +36 -7
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-freshness.d.ts +2 -2
- package/out/fst-freshness.js +2 -2
- package/out/fst-serialize.d.ts +14 -4
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +60 -12
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +35 -1
- package/out/fst-types.d.ts.map +1 -1
- package/out/fts-query.js +1 -1
- package/out/fts-query.js.map +1 -1
- package/out/fts.d.ts +15 -4
- package/out/fts.d.ts.map +1 -1
- package/out/fts.js +15 -4
- package/out/fts.js.map +1 -1
- package/out/geonames-postal.d.ts +2 -2
- package/out/geonames-postal.js +2 -2
- package/out/index.d.ts +4 -2
- package/out/index.d.ts.map +1 -1
- package/out/index.js +3 -2
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts +8 -0
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +91 -19
- package/out/interpolation.js.map +1 -1
- package/out/lookup.d.ts +4 -5
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +102 -444
- package/out/lookup.js.map +1 -1
- package/out/name-score.d.ts +0 -10
- package/out/name-score.d.ts.map +1 -1
- package/out/name-score.js +6 -4
- package/out/name-score.js.map +1 -1
- package/out/place-importance-schema.d.ts +226 -0
- package/out/place-importance-schema.d.ts.map +1 -0
- package/out/place-importance-schema.js +288 -0
- package/out/place-importance-schema.js.map +1 -0
- package/out/poi-lookup.d.ts +1 -1
- package/out/poi-lookup.d.ts.map +1 -1
- package/out/poi-lookup.js +12 -13
- package/out/poi-lookup.js.map +1 -1
- package/out/poi-schema.d.ts +7 -3
- package/out/poi-schema.d.ts.map +1 -1
- package/out/poi-schema.js.map +1 -1
- package/out/polygon-schema.d.ts +37 -0
- package/out/polygon-schema.d.ts.map +1 -0
- package/out/polygon-schema.js +23 -0
- package/out/polygon-schema.js.map +1 -0
- package/out/postal-city-alias-lookup.d.ts +1 -1
- package/out/postal-city-alias-lookup.js +1 -1
- package/out/postal-city-candidate-schema.d.ts +2 -1
- package/out/postal-city-candidate-schema.d.ts.map +1 -1
- package/out/postal-city-candidate-schema.js.map +1 -1
- package/out/postcode-point-lookup.d.ts +1 -1
- package/out/postcode-point-lookup.js +1 -1
- package/out/primary-preference.d.ts +125 -0
- package/out/primary-preference.d.ts.map +1 -0
- package/out/primary-preference.js +138 -0
- package/out/primary-preference.js.map +1 -0
- package/out/proximity-rerank.d.ts +77 -0
- package/out/proximity-rerank.d.ts.map +1 -0
- package/out/proximity-rerank.js +86 -0
- package/out/proximity-rerank.js.map +1 -0
- package/out/region-keys.d.ts +47 -0
- package/out/region-keys.d.ts.map +1 -0
- package/out/region-keys.js +121 -0
- package/out/region-keys.js.map +1 -0
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +6 -9
- package/out/reverse.js.map +1 -1
- package/out/schema.d.ts +1 -1
- package/out/search-fetch.d.ts +57 -0
- package/out/search-fetch.d.ts.map +1 -0
- package/out/search-fetch.js +183 -0
- package/out/search-fetch.js.map +1 -0
- package/out/sharding.d.ts +3 -3
- package/out/sharding.js +1 -1
- package/out/sqlite-convention-source.d.ts +1 -1
- package/out/sqlite-convention-source.js +1 -1
- package/out/sqlite-utils.d.ts +31 -1
- package/out/sqlite-utils.d.ts.map +1 -1
- package/out/sqlite-utils.js +38 -0
- package/out/sqlite-utils.js.map +1 -1
- package/out/street-centroid-schema.d.ts +7 -2
- package/out/street-centroid-schema.d.ts.map +1 -1
- package/out/street-centroid-schema.js.map +1 -1
- package/out/street-centroid.d.ts.map +1 -1
- package/out/street-centroid.js +7 -7
- package/out/street-centroid.js.map +1 -1
- package/out/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +5 -4
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/out/street-normalize.d.ts +83 -9
- package/out/street-normalize.d.ts.map +1 -1
- package/out/street-normalize.js +177 -10
- package/out/street-normalize.js.map +1 -1
- package/out/street-segment-schema.d.ts +6 -2
- package/out/street-segment-schema.d.ts.map +1 -1
- package/out/street-segment-schema.js.map +1 -1
- package/out/types.d.ts +74 -1
- package/out/types.d.ts.map +1 -1
- package/out/unified-schema.d.ts +1 -1
- package/out/unified-schema.js +1 -1
- package/out/uprn-lookup.d.ts +85 -0
- package/out/uprn-lookup.d.ts.map +1 -0
- package/out/uprn-lookup.js +152 -0
- package/out/uprn-lookup.js.map +1 -0
- package/out/uprn-schema.d.ts +93 -0
- package/out/uprn-schema.d.ts.map +1 -0
- package/out/uprn-schema.js +78 -0
- package/out/uprn-schema.js.map +1 -0
- package/out/weights-overlay-linker.d.ts +141 -0
- package/out/weights-overlay-linker.d.ts.map +1 -0
- package/out/weights-overlay-linker.js +259 -0
- package/out/weights-overlay-linker.js.map +1 -0
- package/package.json +296 -16
- package/place-importance-schema.ts +402 -0
- package/poi-lookup.ts +12 -13
- package/poi-schema.ts +8 -3
- package/polygon-schema.ts +47 -0
- package/postal-city-alias-lookup.ts +1 -1
- package/postal-city-candidate-schema.ts +3 -1
- package/postcode-point-lookup.ts +1 -1
- package/primary-preference.ts +207 -0
- package/proximity-rerank.ts +120 -0
- package/region-keys.ts +144 -0
- package/reverse.ts +17 -16
- package/schema.ts +1 -1
- package/search-fetch.ts +256 -0
- package/sharding.ts +3 -3
- package/sqlite-convention-source.ts +1 -1
- package/sqlite-utils.ts +63 -1
- package/street-centroid-schema.ts +8 -2
- package/street-centroid.ts +13 -8
- package/street-morphology-fst-builder.ts +5 -4
- package/street-normalize.ts +254 -24
- package/street-segment-schema.ts +7 -2
- package/types.ts +74 -1
- package/unified-schema.ts +1 -1
- package/uprn-lookup.ts +210 -0
- package/uprn-schema.ts +124 -0
- package/weights-overlay-linker.ts +377 -0
- package/geo.ts +0 -121
- package/out/geo.d.ts +0 -74
- package/out/geo.d.ts.map +0 -1
- package/out/geo.js +0 -71
- package/out/geo.js.map +0 -1
package/README.md
CHANGED
|
@@ -15,11 +15,11 @@ Requires Node 22+ for built-in `node:sqlite`.
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
|
-
import {
|
|
18
|
+
import { WOFSQLitePlaceLookup } from "@mailwoman/resolver-wof-sqlite"
|
|
19
19
|
|
|
20
|
-
const lookup = new
|
|
20
|
+
const lookup = new WOFSQLitePlaceLookup({
|
|
21
21
|
databasePath: "/path/to/whosonfirst-data-admin-us-latest.db",
|
|
22
|
-
|
|
22
|
+
buildFTS: true, // build the FTS5 index on first open (one-time cost)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
const candidates = await lookup.findPlace({
|
|
@@ -35,6 +35,25 @@ for (const c of candidates) {
|
|
|
35
35
|
lookup.close()
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## A shard that cannot answer says so on construction
|
|
39
|
+
|
|
40
|
+
Shards are `ATTACH`ed by a schema name **derived from the filename**, and queries route to them by matching that name against the requested placetype — `postalcode_us` serves `postalcode`. Two ways that used to fail quietly, and both now throw when you build the lookup:
|
|
41
|
+
|
|
42
|
+
- The name does not route. `postcode-ca-overture.db` derives `postcode_ca_overture`, and the router tests `startsWith("postalcode_")` — **"postcode" is not "postalcode"**. It held 843,739 Canadian codes and answered every query with zero hits, which is indistinguishable from "this country has no places".
|
|
43
|
+
- The shard carries `spr` but no `place_search`. It routes, then dies mid-`SELECT`.
|
|
44
|
+
|
|
45
|
+
The predicate is the **table, not the filename**: a shard carrying `spr` is claiming to be a place shard, and every lookup path here reaches the FTS index. A relation-table shard like `postcode-locality-<cc>.db` carries no `spr`, never makes that claim, and is exempt — which is what keeps the documented default shard list working.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
WOFSQLitePlaceLookup: …/postcode-ca-overture.db carries "spr" but no "place_search" table, so it
|
|
49
|
+
cannot serve a lookup. Build it with the FTS index, or leave it out — it is usable as a BUILD input
|
|
50
|
+
either way. Its schema name "postcode_ca_overture" also matches no routed placetype (postalcode,
|
|
51
|
+
locality, region, county, country, venue), so it would never have been queried even with the table
|
|
52
|
+
— check the filename's spelling.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The second sentence appears only when the name routes nowhere. It is the half that turns zero hits into a diagnosis.
|
|
56
|
+
|
|
38
57
|
## Multi-shard (admin + postcode in one connection)
|
|
39
58
|
|
|
40
59
|
Pass an array of paths to open multiple WOF shards on a single connection — each is opened as a
|
|
@@ -44,7 +63,7 @@ separate SQLite schema via `ATTACH DATABASE`. Schema names auto-derive from file
|
|
|
44
63
|
`postalcode_us` shard automatically, everything else hits main.
|
|
45
64
|
|
|
46
65
|
```ts
|
|
47
|
-
const lookup = new
|
|
66
|
+
const lookup = new WOFSQLitePlaceLookup({
|
|
48
67
|
databasePath: ["/data/wof/whosonfirst-data-admin-us-latest.db", "/data/wof/whosonfirst-data-postalcode-us-latest.db"],
|
|
49
68
|
})
|
|
50
69
|
|
|
@@ -55,7 +74,7 @@ await lookup.findPlace({ text: "62701", placetype: "postalcode" }) // → postco
|
|
|
55
74
|
Override schema names or routing explicitly when needed:
|
|
56
75
|
|
|
57
76
|
```ts
|
|
58
|
-
new
|
|
77
|
+
new WOFSQLitePlaceLookup({
|
|
59
78
|
databasePath: ["/data/wof/admin.db", { path: "/data/oddly-named.db", schemaName: "pc", placetypes: ["postalcode"] }],
|
|
60
79
|
})
|
|
61
80
|
```
|
|
@@ -83,7 +102,7 @@ bunzip2 whosonfirst-data-admin-us-latest.db.bz2
|
|
|
83
102
|
|
|
84
103
|
Upstream WOF SQLite distributions ship a `places` table but **not** an FTS5 index. The resolver needs FTS5 to do fast prefix + token-bag matching. Two options:
|
|
85
104
|
|
|
86
|
-
1. **`
|
|
105
|
+
1. **`buildFTS: true` on construction** — builds the index lazily on first open. Cost is one-time but expensive (~minutes on the full US admin shard). Use for prototyping.
|
|
87
106
|
2. **Pre-build the index with `mailwoman gazetteer build fts`** — ship the DB with the index included so first-open is fast. Recommended for production.
|
|
88
107
|
|
|
89
108
|
### `mailwoman gazetteer build fts`
|
|
@@ -143,7 +162,7 @@ What survives in the slim DB:
|
|
|
143
162
|
|
|
144
163
|
What gets dropped: the `geojson` table, which is build-time only — `lookup.ts` never reads it at query time, and it accounts for ~95% of the on-disk size. The `place_population` aux table consumes `wof:population` from geojson before we drop it.
|
|
145
164
|
|
|
146
|
-
`
|
|
165
|
+
`WOFSQLitePlaceLookup` opens the slim DB without any code change. Out-of-set queries (a locality not in the top-K) correctly return zero hits.
|
|
147
166
|
|
|
148
167
|
You can also build the index programmatically via the package's `./fts` subpath:
|
|
149
168
|
|
|
@@ -209,7 +228,7 @@ R*Tree because it computes haversine distance against the centroid columns direc
|
|
|
209
228
|
All weights are configurable via the second ctor argument:
|
|
210
229
|
|
|
211
230
|
```ts
|
|
212
|
-
new
|
|
231
|
+
new WOFSQLitePlaceLookup({ databasePath }, { countryMatchBoost: 0.5 })
|
|
213
232
|
```
|
|
214
233
|
|
|
215
234
|
Defaults are in `lookup.ts::DEFAULT_WEIGHTS`.
|
|
@@ -246,7 +265,7 @@ Coverage includes: placetype filtering, country filtering, the empty-result case
|
|
|
246
265
|
|
|
247
266
|
## Concurrency model
|
|
248
267
|
|
|
249
|
-
This package opens a single `node:sqlite` connection per `
|
|
268
|
+
This package opens a single `node:sqlite` connection per `WOFSQLitePlaceLookup` instance. SQLite is single-writer / many-reader; the Kysely wrapper around the connection serializes all queries through a mutex. For high-concurrency HTTP servers, instantiate one resolver per request handler or per pool slot — sharing a single instance across concurrent requests is fine (queries queue) but won't parallelize across cores.
|
|
250
269
|
|
|
251
270
|
## License
|
|
252
271
|
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
import { DatabaseSync } from "node:sqlite"
|
|
37
37
|
|
|
38
38
|
import type { InterpolationLookup } from "@mailwoman/resolver"
|
|
39
|
+
import { haversineKm } from "@mailwoman/spatial"
|
|
39
40
|
|
|
40
|
-
import { haversineKm } from "./geo.ts"
|
|
41
41
|
import type { InterpolatedHit, InterpolationQuery, StreetInterpolator } from "./interpolation.ts"
|
|
42
|
-
import { hasTable } from "./sqlite-utils.ts"
|
|
43
|
-
import { canonicalizeRouteKey,
|
|
42
|
+
import { hasTable, prepareAll, type PreparedAll } from "./sqlite-utils.ts"
|
|
43
|
+
import { canonicalizeRouteKey, type RouteKey, streetKeyVariants } from "./street-normalize.ts"
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Extrapolation cap for a single-sided bracket: at most one pair-span beyond the nearest known point (`t = 2`). Past
|
|
@@ -71,7 +71,7 @@ export class AddressPointInterpolator implements InterpolationLookup {
|
|
|
71
71
|
readonly #db: DatabaseSync
|
|
72
72
|
readonly #ownsDB: boolean
|
|
73
73
|
readonly #fallback: StreetInterpolator | undefined
|
|
74
|
-
readonly #byPostcode:
|
|
74
|
+
readonly #byPostcode: PreparedAll<[postcode: string, street: RouteKey, number: number], PointRow> | undefined
|
|
75
75
|
|
|
76
76
|
constructor(opts: { dbPath?: string; database?: DatabaseSync; fallback?: StreetInterpolator }) {
|
|
77
77
|
if (opts.database) {
|
|
@@ -91,7 +91,8 @@ export class AddressPointInterpolator implements InterpolationLookup {
|
|
|
91
91
|
if (hasTable(this.#db, "address_point")) {
|
|
92
92
|
// Strictly-numeric neighbor numbers on the route-folded street key within the ZIP. The
|
|
93
93
|
// queried number itself is excluded HERE (see module doc: non-circular by construction).
|
|
94
|
-
this.#byPostcode =
|
|
94
|
+
this.#byPostcode = prepareAll(
|
|
95
|
+
this.#db,
|
|
95
96
|
`SELECT CAST(number AS INTEGER) AS n, lat, lon, source, release
|
|
96
97
|
FROM address_point
|
|
97
98
|
WHERE postcode = ? AND street_key = ?
|
|
@@ -102,16 +103,25 @@ export class AddressPointInterpolator implements InterpolationLookup {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
find(query: InterpolationQuery): InterpolatedHit | null {
|
|
105
|
-
const streetKey = canonicalizeRouteKey(normalizeStreetForKey(query.street))
|
|
106
106
|
const numberRaw = query.number.trim()
|
|
107
107
|
|
|
108
|
-
if (
|
|
108
|
+
if (!/^\d+$/.test(numberRaw)) return null
|
|
109
109
|
const n = Number(numberRaw)
|
|
110
110
|
|
|
111
111
|
// No own table (empty shard) or no postcode → defer to the segment fallback rather than query.
|
|
112
112
|
if (!this.#byPostcode || !query.postcode) return this.#fallback?.find(query) ?? null
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
// Key-variant ladder (see `streetKeyVariants`) — same probe order as the exact-point reader.
|
|
115
|
+
let rows: PointRow[] = []
|
|
116
|
+
|
|
117
|
+
for (const variant of streetKeyVariants(query.street)) {
|
|
118
|
+
const streetKey = canonicalizeRouteKey(variant)
|
|
119
|
+
|
|
120
|
+
rows = this.#byPostcode(query.postcode.trim(), streetKey, n)
|
|
121
|
+
|
|
122
|
+
if (rows.length) break
|
|
123
|
+
}
|
|
124
|
+
|
|
115
125
|
const hit = rows.length >= 2 ? interpolateFromNeighbors(rows, n) : null
|
|
116
126
|
|
|
117
127
|
return hit ?? this.#fallback?.find(query) ?? null
|
package/address-point-schema.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import type { Kysely } from "kysely"
|
|
20
20
|
|
|
21
|
+
import type { NameKey, RouteKey, StreetKey } from "./street-normalize.ts"
|
|
22
|
+
|
|
21
23
|
/**
|
|
22
24
|
* One rooftop address point. `(street_norm, number)` within a `postcode` (preferred) or `locality_norm` scope is the
|
|
23
25
|
* lookup; `street_key` is the #483 route-fold key for interpolation. Coordinates are non-null (the builder drops
|
|
@@ -27,11 +29,12 @@ export interface AddressPointTable {
|
|
|
27
29
|
/**
|
|
28
30
|
* Shared {@link normalizeStreetForKey} of the street — the build/query-consistent probe key.
|
|
29
31
|
*/
|
|
30
|
-
street_norm:
|
|
32
|
+
street_norm: StreetKey
|
|
31
33
|
/**
|
|
32
|
-
* `canonicalizeRouteKey(street_norm)` — the route-fold key (#483 Method 2).
|
|
34
|
+
* `canonicalizeRouteKey(street_norm)` — the route-fold key (#483 Method 2). Its own brand, so it cannot be
|
|
35
|
+
* interchanged with the plain `street_norm` above.
|
|
33
36
|
*/
|
|
34
|
-
street_key:
|
|
37
|
+
street_key: RouteKey
|
|
35
38
|
/**
|
|
36
39
|
* House number, normalized lower-case (kept TEXT — "123-A", "12 1/2" must survive).
|
|
37
40
|
*/
|
|
@@ -41,7 +44,7 @@ export interface AddressPointTable {
|
|
|
41
44
|
/**
|
|
42
45
|
* Shared {@link normalizeLocalityForKey} of the locality — the fallback scope.
|
|
43
46
|
*/
|
|
44
|
-
locality_norm:
|
|
47
|
+
locality_norm: NameKey | null
|
|
45
48
|
/**
|
|
46
49
|
* The street as it appeared in the source (kept for display / debugging).
|
|
47
50
|
*/
|
|
@@ -65,6 +68,15 @@ export interface AddressPointDatabase {
|
|
|
65
68
|
address_point: AddressPointTable
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The slice of a Kysely handle the `address_point` DDL touches — the parameter type its builders take.
|
|
73
|
+
*
|
|
74
|
+
* Kysely is invariant in its schema parameter (the incompatibility is in `transaction()`), so a shard that EXTENDS
|
|
75
|
+
* `AddressPointTable` — OSM adds `h3_cell` — cannot pass its own handle to a `Kysely<AddressPointDatabase>` parameter.
|
|
76
|
+
* Naming only `schema` lets it, and the DDL below needs nothing else.
|
|
77
|
+
*/
|
|
78
|
+
export type AddressPointSchemaHandle = Pick<Kysely<AddressPointDatabase>, "schema">
|
|
79
|
+
|
|
68
80
|
/**
|
|
69
81
|
* The `address_point` columns in INSERT order. The builder's positional prepared statement derives its placeholder list
|
|
70
82
|
* from this, so the positional order can't drift from the DDL / the reader.
|
|
@@ -86,7 +98,7 @@ export const ADDRESS_POINT_COLUMNS = [
|
|
|
86
98
|
/**
|
|
87
99
|
* Create the `address_point` table — called before the streaming bulk load.
|
|
88
100
|
*/
|
|
89
|
-
export async function createAddressPointTable(db:
|
|
101
|
+
export async function createAddressPointTable(db: AddressPointSchemaHandle): Promise<void> {
|
|
90
102
|
await db.schema
|
|
91
103
|
.createTable("address_point")
|
|
92
104
|
.addColumn("street_norm", "text", (c) => c.notNull())
|
|
@@ -107,7 +119,7 @@ export async function createAddressPointTable(db: Kysely<AddressPointDatabase>):
|
|
|
107
119
|
/**
|
|
108
120
|
* Create the three probe indexes the reader relies on (postcode-scope, locality-scope, route-key).
|
|
109
121
|
*/
|
|
110
|
-
export async function createAddressPointIndexes(db:
|
|
122
|
+
export async function createAddressPointIndexes(db: AddressPointSchemaHandle): Promise<void> {
|
|
111
123
|
await db.schema
|
|
112
124
|
.createIndex("idx_ap_postcode")
|
|
113
125
|
.on("address_point")
|
package/address-point.ts
CHANGED
|
@@ -20,10 +20,13 @@ import { DatabaseSync } from "node:sqlite"
|
|
|
20
20
|
import type { AddressPointHit, AddressPointLookup } from "@mailwoman/resolver"
|
|
21
21
|
|
|
22
22
|
import type { AddressPointTable } from "./address-point-schema.ts"
|
|
23
|
-
import { hasTable } from "./sqlite-utils.ts"
|
|
23
|
+
import { hasTable, prepareGet, type PreparedGet } from "./sqlite-utils.ts"
|
|
24
24
|
import {
|
|
25
25
|
normalizeLocalityForKey,
|
|
26
|
-
|
|
26
|
+
type NameKey,
|
|
27
|
+
type StreetKey,
|
|
28
|
+
streetKeyVariants,
|
|
29
|
+
streetLocaleForSurface,
|
|
27
30
|
stripArrondissement,
|
|
28
31
|
type StreetLocale,
|
|
29
32
|
} from "./street-normalize.ts"
|
|
@@ -32,20 +35,25 @@ import {
|
|
|
32
35
|
* The columns this lookup projects — a typed slice of the SHARED {@link AddressPointTable}, so a column rename in
|
|
33
36
|
* `build-address-point-shard.ts` (the writer) is a compile error here (the reader).
|
|
34
37
|
*/
|
|
35
|
-
type AddressPointRow = Pick<AddressPointTable, "lat" | "lon" | "source" | "release">
|
|
38
|
+
type AddressPointRow = Pick<AddressPointTable, "lat" | "lon" | "source" | "release" | "locality_norm" | "postcode">
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* The 4 columns the reader SELECTs, in the schema's order — referenced by the prepared SELECTs so the projected
|
|
39
42
|
* `AddressPointRow` stays in lockstep with the shared schema.
|
|
40
43
|
*/
|
|
41
|
-
const SELECT_COLS = "lat, lon, source, release"
|
|
44
|
+
const SELECT_COLS = "lat, lon, source, release, locality_norm, postcode"
|
|
42
45
|
|
|
43
46
|
export class AddressPointSqliteLookup implements AddressPointLookup {
|
|
44
47
|
readonly #db: DatabaseSync
|
|
45
48
|
readonly #locale: StreetLocale
|
|
46
|
-
readonly #byPostcode:
|
|
47
|
-
readonly #byLocality:
|
|
48
|
-
readonly #byBbox:
|
|
49
|
+
readonly #byPostcode: PreparedGet<[postcode: string, street: StreetKey, number: string], AddressPointRow> | undefined
|
|
50
|
+
readonly #byLocality: PreparedGet<[locality: NameKey, street: StreetKey, number: string], AddressPointRow> | undefined
|
|
51
|
+
readonly #byBbox:
|
|
52
|
+
| PreparedGet<
|
|
53
|
+
[street: StreetKey, number: string, minLat: number, maxLat: number, minLon: number, maxLon: number],
|
|
54
|
+
AddressPointRow
|
|
55
|
+
>
|
|
56
|
+
| undefined
|
|
49
57
|
|
|
50
58
|
/**
|
|
51
59
|
* @param dbPath Shard path.
|
|
@@ -59,17 +67,20 @@ export class AddressPointSqliteLookup implements AddressPointLookup {
|
|
|
59
67
|
// Degrade gracefully on an empty/tableless shard (interrupted build, stray 0-byte file): with no
|
|
60
68
|
// `address_point` table this lookup is a no-op miss, not a crash that loses the whole state (#568).
|
|
61
69
|
if (hasTable(this.#db, "address_point")) {
|
|
62
|
-
this.#byPostcode =
|
|
70
|
+
this.#byPostcode = prepareGet(
|
|
71
|
+
this.#db,
|
|
63
72
|
`SELECT ${SELECT_COLS} FROM address_point
|
|
64
73
|
WHERE postcode = ? AND street_norm = ? AND number = ? LIMIT 1`
|
|
65
74
|
)
|
|
66
75
|
|
|
67
|
-
this.#byLocality =
|
|
76
|
+
this.#byLocality = prepareGet(
|
|
77
|
+
this.#db,
|
|
68
78
|
`SELECT ${SELECT_COLS} FROM address_point
|
|
69
79
|
WHERE locality_norm = ? AND street_norm = ? AND number = ? LIMIT 1`
|
|
70
80
|
)
|
|
71
81
|
|
|
72
|
-
this.#byBbox =
|
|
82
|
+
this.#byBbox = prepareGet(
|
|
83
|
+
this.#db,
|
|
73
84
|
`SELECT ${SELECT_COLS} FROM address_point
|
|
74
85
|
WHERE street_norm = ? AND number = ? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ? LIMIT 1`
|
|
75
86
|
)
|
|
@@ -84,15 +95,99 @@ export class AddressPointSqliteLookup implements AddressPointLookup {
|
|
|
84
95
|
bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
|
|
85
96
|
}): AddressPointHit | null {
|
|
86
97
|
if (!this.#byPostcode || !this.#byLocality || !this.#byBbox) return null
|
|
87
|
-
const streetNorm = normalizeStreetForKeyLocale(query.street, this.#locale)
|
|
88
98
|
const number = query.number.trim().toLowerCase()
|
|
89
99
|
|
|
90
|
-
if (!
|
|
100
|
+
if (!number) return null
|
|
91
101
|
|
|
102
|
+
// Key-variant ladder (see `streetKeyVariants`): the literal key first, then the doubled-type
|
|
103
|
+
// collapse and the saint↔st register swap — each variant runs the FULL number ladder below, and
|
|
104
|
+
// the first variant to answer wins, so an attested literal key is never second-guessed.
|
|
105
|
+
let row: AddressPointRow | undefined
|
|
106
|
+
|
|
107
|
+
for (const streetNorm of streetKeyVariants(query.street, streetLocaleForSurface(query.street, this.#locale))) {
|
|
108
|
+
row = this.#findForKey(streetNorm, number, query)
|
|
109
|
+
|
|
110
|
+
if (row) break
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!row) return null
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
lat: row.lat,
|
|
117
|
+
lon: row.lon,
|
|
118
|
+
source: row.source,
|
|
119
|
+
release: row.release,
|
|
120
|
+
...(row.locality_norm ? { localityNorm: row.locality_norm } : {}),
|
|
121
|
+
...(row.postcode ? { postcode: row.postcode } : {}),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The full number ladder for ONE normalized street key: exact, then the range low-end, then letter-suffix spacing
|
|
127
|
+
* with the base-number fall — see each rung's note in place.
|
|
128
|
+
*/
|
|
129
|
+
#findForKey(
|
|
130
|
+
streetNorm: StreetKey,
|
|
131
|
+
number: string,
|
|
132
|
+
query: {
|
|
133
|
+
street: string
|
|
134
|
+
number: string
|
|
135
|
+
postcode?: string
|
|
136
|
+
locality?: string
|
|
137
|
+
bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
|
|
138
|
+
}
|
|
139
|
+
) {
|
|
140
|
+
let row = this.#probe(streetNorm, number, query)
|
|
141
|
+
|
|
142
|
+
// Range-surface fallback: every register this reader serves stores ONE number per point
|
|
143
|
+
// (G-NAF `NUMBER_FIRST`, BAN, OA, OSM `addr:housenumber`), but the attested surface is often
|
|
144
|
+
// a range — "385-387 Esplanade" keys `385`. Null-only: an exact range key that matched above
|
|
145
|
+
// (some OSM points DO carry "385-387" verbatim) is never second-guessed.
|
|
146
|
+
if (!row) {
|
|
147
|
+
const low = /^(\d+[a-z]?)-\d+[a-z]?$/.exec(number)?.[1]
|
|
148
|
+
|
|
149
|
+
if (low) {
|
|
150
|
+
row = this.#probe(streetNorm, low, query)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Letter-suffix spacing fallback: the registers disagree on the joint — BAN stores "3 a"
|
|
155
|
+
// (space-separated), G-NAF and most OA sources store "3a" — and the parsed surface can arrive
|
|
156
|
+
// either way. On a miss, retry the OTHER spacing; on a double miss, the BASE number (the
|
|
157
|
+
// register attests no 3A but does attest 3 — the adjacent-parcel approximation, priced the
|
|
158
|
+
// same as the range fallback's low end). Null-only throughout, and only for the
|
|
159
|
+
// digits+single-letter shape (never touches "12 1/2" or unit-bearing forms).
|
|
160
|
+
if (!row) {
|
|
161
|
+
const joined = /^(\d+)\s+([a-z])$/.exec(number)
|
|
162
|
+
const spaced = /^(\d+)([a-z])$/.exec(number)
|
|
163
|
+
|
|
164
|
+
if (joined) {
|
|
165
|
+
row = this.#probe(streetNorm, `${joined[1]}${joined[2]}`, query) ?? this.#probe(streetNorm, joined[1]!, query)
|
|
166
|
+
} else if (spaced) {
|
|
167
|
+
row = this.#probe(streetNorm, `${spaced[1]} ${spaced[2]}`, query) ?? this.#probe(streetNorm, spaced[1]!, query)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return row
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The scope ladder for one (street, number) key: postcode, then locality, then the bbox fall-through — each rung only
|
|
176
|
+
* when the prior missed.
|
|
177
|
+
*/
|
|
178
|
+
#probe(
|
|
179
|
+
streetNorm: StreetKey,
|
|
180
|
+
number: string,
|
|
181
|
+
query: {
|
|
182
|
+
postcode?: string
|
|
183
|
+
locality?: string
|
|
184
|
+
bbox?: { minLat: number; maxLat: number; minLon: number; maxLon: number }
|
|
185
|
+
}
|
|
186
|
+
): AddressPointRow | undefined {
|
|
92
187
|
let row: AddressPointRow | undefined
|
|
93
188
|
|
|
94
189
|
if (query.postcode) {
|
|
95
|
-
row = this.#byPostcode
|
|
190
|
+
row = this.#byPostcode!(query.postcode.trim(), streetNorm, number)
|
|
96
191
|
}
|
|
97
192
|
|
|
98
193
|
if (!row && query.locality) {
|
|
@@ -104,19 +199,17 @@ export class AddressPointSqliteLookup implements AddressPointLookup {
|
|
|
104
199
|
? stripArrondissement(normalizeLocalityForKey(query.locality))
|
|
105
200
|
: normalizeLocalityForKey(query.locality)
|
|
106
201
|
|
|
107
|
-
row = this.#byLocality
|
|
202
|
+
row = this.#byLocality!(localityKey, streetNorm, number)
|
|
108
203
|
}
|
|
109
204
|
|
|
110
205
|
// Bbox fall-through (#247): the point carries no postcode/locality of its own, but its coordinate falls
|
|
111
206
|
// inside the resolved locality's box. Only reached when the scoped probes missed AND a bbox was supplied.
|
|
112
207
|
if (!row && query.bbox) {
|
|
113
208
|
const b = query.bbox
|
|
114
|
-
row = this.#byBbox
|
|
209
|
+
row = this.#byBbox!(streetNorm, number, b.minLat, b.maxLat, b.minLon, b.maxLon)
|
|
115
210
|
}
|
|
116
211
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return { lat: row.lat, lon: row.lon, source: row.source, release: row.release }
|
|
212
|
+
return row
|
|
120
213
|
}
|
|
121
214
|
|
|
122
215
|
close(): void {
|
package/ancestry.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* joined with `spr` for canonical names + centroids, ordered NEAREST-FIRST (deepest placetype
|
|
8
8
|
* first, country last).
|
|
9
9
|
*
|
|
10
|
-
* Factored out of `
|
|
10
|
+
* Factored out of `WOFSQLitePlaceLookup.ancestors()` (#404) so the reverse geocoder (`reverse.ts`,
|
|
11
11
|
* #484) reuses the SAME walk instead of growing a second one. The placetype-specificity ordering
|
|
12
12
|
* lives here as `PLACETYPE_DEPTH` — a single TS map instead of the previous SQL CASE, and
|
|
13
13
|
* extended below `localadmin` (locality/borough/neighbourhood/microhood now rank correctly
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import type { DatabaseSync } from "node:sqlite"
|
|
19
19
|
|
|
20
|
+
import { allRows } from "./sqlite-utils.ts"
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* WOF placetype → containment depth, coarsest = 1. Higher = finer. Placetypes we never resolve (continent, empire, …)
|
|
22
24
|
* map to 0 and sort last. NOT the same table as the FST's `PLACETYPE_ORDER` (fst-serialize.ts) — that one is a
|
|
@@ -57,17 +59,18 @@ export interface AncestorPlaceRow {
|
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
61
|
* The ancestor lineage of `id` — self excluded, nearest-first. Returns `[]` when the place has no recorded ancestry.
|
|
60
|
-
* NOT memoized here; `
|
|
62
|
+
* NOT memoized here; `WOFSQLitePlaceLookup` keeps its own per-id cache.
|
|
61
63
|
*/
|
|
62
64
|
export function ancestorLineage(db: DatabaseSync, id: number, schemaName = "main"): AncestorPlaceRow[] {
|
|
63
|
-
const rows =
|
|
64
|
-
.prepare(
|
|
65
|
+
const rows = allRows<AncestorPlaceRow>(
|
|
66
|
+
db.prepare(
|
|
65
67
|
`SELECT a.ancestor_id AS id, a.ancestor_placetype AS placetype, s.name AS name,
|
|
66
68
|
s.country AS country, s.latitude AS lat, s.longitude AS lon
|
|
67
69
|
FROM ${schemaName}.ancestors a JOIN ${schemaName}.spr s ON s.id = a.ancestor_id
|
|
68
70
|
WHERE a.id = ? AND a.ancestor_id != a.id`
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
+
),
|
|
72
|
+
id
|
|
73
|
+
)
|
|
71
74
|
|
|
72
75
|
rows.sort((a, b) => placetypeDepth(b.placetype) - placetypeDepth(a.placetype))
|
|
73
76
|
|