@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.
Files changed (297) hide show
  1. package/README.md +28 -9
  2. package/address-point-interpolation.ts +18 -8
  3. package/address-point-schema.ts +18 -6
  4. package/address-point.ts +111 -18
  5. package/ancestry.ts +9 -6
  6. package/build-candidate.ts +287 -157
  7. package/build-slim.ts +3 -3
  8. package/candidate/alias-bags.ts +54 -0
  9. package/candidate/ancestors-sidecar.ts +206 -0
  10. package/candidate/country-display-names.ts +79 -0
  11. package/candidate/name-roles.ts +237 -0
  12. package/candidate/own-name.ts +146 -0
  13. package/candidate/place-attrs.ts +44 -0
  14. package/candidate/shard-fold.ts +137 -0
  15. package/candidate-ancestors-schema.ts +195 -0
  16. package/candidate-fts.ts +4 -2
  17. package/candidate-importance.ts +228 -0
  18. package/candidate-lookup.ts +564 -174
  19. package/candidate-schema.ts +60 -3
  20. package/candidate-scoring.ts +268 -0
  21. package/capital-schema.ts +90 -0
  22. package/capitals.ts +148 -0
  23. package/coincident-roles.ts +69 -10
  24. package/convention-schema.ts +72 -0
  25. package/convention.ts +2 -2
  26. package/coverage-manifest-schema.ts +7 -7
  27. package/currency-backfill.ts +249 -0
  28. package/exact-match.ts +104 -0
  29. package/fst-autocomplete.ts +105 -122
  30. package/fst-builder.ts +39 -47
  31. package/fst-deserialize-web.ts +43 -7
  32. package/fst-freshness.ts +2 -2
  33. package/fst-serialize.ts +68 -12
  34. package/fst-types.ts +35 -1
  35. package/fts-query.ts +1 -1
  36. package/fts.ts +16 -4
  37. package/geonames-postal.ts +2 -2
  38. package/index.ts +26 -14
  39. package/interpolation.ts +113 -19
  40. package/lookup.ts +118 -560
  41. package/name-score.ts +6 -4
  42. package/out/address-point-interpolation.d.ts.map +1 -1
  43. package/out/address-point-interpolation.js +13 -7
  44. package/out/address-point-interpolation.js.map +1 -1
  45. package/out/address-point-schema.d.ts +16 -6
  46. package/out/address-point-schema.d.ts.map +1 -1
  47. package/out/address-point-schema.js.map +1 -1
  48. package/out/address-point.d.ts.map +1 -1
  49. package/out/address-point.js +70 -14
  50. package/out/address-point.js.map +1 -1
  51. package/out/ancestry.d.ts +2 -2
  52. package/out/ancestry.d.ts.map +1 -1
  53. package/out/ancestry.js +5 -6
  54. package/out/ancestry.js.map +1 -1
  55. package/out/build-candidate.d.ts +108 -0
  56. package/out/build-candidate.d.ts.map +1 -1
  57. package/out/build-candidate.js +151 -120
  58. package/out/build-candidate.js.map +1 -1
  59. package/out/build-slim.d.ts +1 -1
  60. package/out/build-slim.js +3 -3
  61. package/out/build-slim.js.map +1 -1
  62. package/out/candidate/alias-bags.d.ts +17 -0
  63. package/out/candidate/alias-bags.d.ts.map +1 -0
  64. package/out/candidate/alias-bags.js +39 -0
  65. package/out/candidate/alias-bags.js.map +1 -0
  66. package/out/candidate/ancestors-sidecar.d.ts +33 -0
  67. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  68. package/out/candidate/ancestors-sidecar.js +140 -0
  69. package/out/candidate/ancestors-sidecar.js.map +1 -0
  70. package/out/candidate/country-display-names.d.ts +35 -0
  71. package/out/candidate/country-display-names.d.ts.map +1 -0
  72. package/out/candidate/country-display-names.js +59 -0
  73. package/out/candidate/country-display-names.js.map +1 -0
  74. package/out/candidate/name-roles.d.ts +55 -0
  75. package/out/candidate/name-roles.d.ts.map +1 -0
  76. package/out/candidate/name-roles.js +165 -0
  77. package/out/candidate/name-roles.js.map +1 -0
  78. package/out/candidate/own-name.d.ts +50 -0
  79. package/out/candidate/own-name.d.ts.map +1 -0
  80. package/out/candidate/own-name.js +132 -0
  81. package/out/candidate/own-name.js.map +1 -0
  82. package/out/candidate/place-attrs.d.ts +43 -0
  83. package/out/candidate/place-attrs.d.ts.map +1 -0
  84. package/out/candidate/place-attrs.js +15 -0
  85. package/out/candidate/place-attrs.js.map +1 -0
  86. package/out/candidate/shard-fold.d.ts +31 -0
  87. package/out/candidate/shard-fold.d.ts.map +1 -0
  88. package/out/candidate/shard-fold.js +104 -0
  89. package/out/candidate/shard-fold.js.map +1 -0
  90. package/out/candidate-ancestors-schema.d.ts +150 -0
  91. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  92. package/out/candidate-ancestors-schema.js +123 -0
  93. package/out/candidate-ancestors-schema.js.map +1 -0
  94. package/out/candidate-fts.d.ts +4 -2
  95. package/out/candidate-fts.d.ts.map +1 -1
  96. package/out/candidate-fts.js +4 -2
  97. package/out/candidate-fts.js.map +1 -1
  98. package/out/candidate-importance.d.ts +132 -0
  99. package/out/candidate-importance.d.ts.map +1 -0
  100. package/out/candidate-importance.js +174 -0
  101. package/out/candidate-importance.js.map +1 -0
  102. package/out/candidate-lookup.d.ts +22 -37
  103. package/out/candidate-lookup.d.ts.map +1 -1
  104. package/out/candidate-lookup.js +446 -132
  105. package/out/candidate-lookup.js.map +1 -1
  106. package/out/candidate-schema.d.ts +52 -4
  107. package/out/candidate-schema.d.ts.map +1 -1
  108. package/out/candidate-schema.js +8 -0
  109. package/out/candidate-schema.js.map +1 -1
  110. package/out/candidate-scoring.d.ts +34 -0
  111. package/out/candidate-scoring.d.ts.map +1 -0
  112. package/out/candidate-scoring.js +200 -0
  113. package/out/candidate-scoring.js.map +1 -0
  114. package/out/capital-schema.d.ts +51 -0
  115. package/out/capital-schema.d.ts.map +1 -0
  116. package/out/capital-schema.js +63 -0
  117. package/out/capital-schema.js.map +1 -0
  118. package/out/capitals.d.ts +69 -0
  119. package/out/capitals.d.ts.map +1 -0
  120. package/out/capitals.js +98 -0
  121. package/out/capitals.js.map +1 -0
  122. package/out/coincident-roles.d.ts +7 -0
  123. package/out/coincident-roles.d.ts.map +1 -1
  124. package/out/coincident-roles.js +42 -8
  125. package/out/coincident-roles.js.map +1 -1
  126. package/out/convention-schema.d.ts +51 -0
  127. package/out/convention-schema.d.ts.map +1 -0
  128. package/out/convention-schema.js +34 -0
  129. package/out/convention-schema.js.map +1 -0
  130. package/out/convention.d.ts +1 -1
  131. package/out/convention.js +2 -2
  132. package/out/coverage-manifest-schema.js +3 -7
  133. package/out/coverage-manifest-schema.js.map +1 -1
  134. package/out/currency-backfill.d.ts +46 -0
  135. package/out/currency-backfill.d.ts.map +1 -0
  136. package/out/currency-backfill.js +180 -0
  137. package/out/currency-backfill.js.map +1 -0
  138. package/out/exact-match.d.ts +25 -0
  139. package/out/exact-match.d.ts.map +1 -0
  140. package/out/exact-match.js +89 -0
  141. package/out/exact-match.js.map +1 -0
  142. package/out/fst-autocomplete.d.ts +24 -14
  143. package/out/fst-autocomplete.d.ts.map +1 -1
  144. package/out/fst-autocomplete.js +84 -100
  145. package/out/fst-autocomplete.js.map +1 -1
  146. package/out/fst-builder.d.ts.map +1 -1
  147. package/out/fst-builder.js +32 -40
  148. package/out/fst-builder.js.map +1 -1
  149. package/out/fst-deserialize-web.d.ts.map +1 -1
  150. package/out/fst-deserialize-web.js +36 -7
  151. package/out/fst-deserialize-web.js.map +1 -1
  152. package/out/fst-freshness.d.ts +2 -2
  153. package/out/fst-freshness.js +2 -2
  154. package/out/fst-serialize.d.ts +14 -4
  155. package/out/fst-serialize.d.ts.map +1 -1
  156. package/out/fst-serialize.js +60 -12
  157. package/out/fst-serialize.js.map +1 -1
  158. package/out/fst-types.d.ts +35 -1
  159. package/out/fst-types.d.ts.map +1 -1
  160. package/out/fts-query.js +1 -1
  161. package/out/fts-query.js.map +1 -1
  162. package/out/fts.d.ts +15 -4
  163. package/out/fts.d.ts.map +1 -1
  164. package/out/fts.js +15 -4
  165. package/out/fts.js.map +1 -1
  166. package/out/geonames-postal.d.ts +2 -2
  167. package/out/geonames-postal.js +2 -2
  168. package/out/index.d.ts +4 -2
  169. package/out/index.d.ts.map +1 -1
  170. package/out/index.js +3 -2
  171. package/out/index.js.map +1 -1
  172. package/out/interpolation.d.ts +8 -0
  173. package/out/interpolation.d.ts.map +1 -1
  174. package/out/interpolation.js +91 -19
  175. package/out/interpolation.js.map +1 -1
  176. package/out/lookup.d.ts +4 -5
  177. package/out/lookup.d.ts.map +1 -1
  178. package/out/lookup.js +102 -444
  179. package/out/lookup.js.map +1 -1
  180. package/out/name-score.d.ts +0 -10
  181. package/out/name-score.d.ts.map +1 -1
  182. package/out/name-score.js +6 -4
  183. package/out/name-score.js.map +1 -1
  184. package/out/place-importance-schema.d.ts +226 -0
  185. package/out/place-importance-schema.d.ts.map +1 -0
  186. package/out/place-importance-schema.js +288 -0
  187. package/out/place-importance-schema.js.map +1 -0
  188. package/out/poi-lookup.d.ts +1 -1
  189. package/out/poi-lookup.d.ts.map +1 -1
  190. package/out/poi-lookup.js +12 -13
  191. package/out/poi-lookup.js.map +1 -1
  192. package/out/poi-schema.d.ts +7 -3
  193. package/out/poi-schema.d.ts.map +1 -1
  194. package/out/poi-schema.js.map +1 -1
  195. package/out/polygon-schema.d.ts +37 -0
  196. package/out/polygon-schema.d.ts.map +1 -0
  197. package/out/polygon-schema.js +23 -0
  198. package/out/polygon-schema.js.map +1 -0
  199. package/out/postal-city-alias-lookup.d.ts +1 -1
  200. package/out/postal-city-alias-lookup.js +1 -1
  201. package/out/postal-city-candidate-schema.d.ts +2 -1
  202. package/out/postal-city-candidate-schema.d.ts.map +1 -1
  203. package/out/postal-city-candidate-schema.js.map +1 -1
  204. package/out/postcode-point-lookup.d.ts +1 -1
  205. package/out/postcode-point-lookup.js +1 -1
  206. package/out/primary-preference.d.ts +125 -0
  207. package/out/primary-preference.d.ts.map +1 -0
  208. package/out/primary-preference.js +138 -0
  209. package/out/primary-preference.js.map +1 -0
  210. package/out/proximity-rerank.d.ts +77 -0
  211. package/out/proximity-rerank.d.ts.map +1 -0
  212. package/out/proximity-rerank.js +86 -0
  213. package/out/proximity-rerank.js.map +1 -0
  214. package/out/region-keys.d.ts +47 -0
  215. package/out/region-keys.d.ts.map +1 -0
  216. package/out/region-keys.js +121 -0
  217. package/out/region-keys.js.map +1 -0
  218. package/out/reverse.d.ts.map +1 -1
  219. package/out/reverse.js +6 -9
  220. package/out/reverse.js.map +1 -1
  221. package/out/schema.d.ts +1 -1
  222. package/out/search-fetch.d.ts +57 -0
  223. package/out/search-fetch.d.ts.map +1 -0
  224. package/out/search-fetch.js +183 -0
  225. package/out/search-fetch.js.map +1 -0
  226. package/out/sharding.d.ts +3 -3
  227. package/out/sharding.js +1 -1
  228. package/out/sqlite-convention-source.d.ts +1 -1
  229. package/out/sqlite-convention-source.js +1 -1
  230. package/out/sqlite-utils.d.ts +31 -1
  231. package/out/sqlite-utils.d.ts.map +1 -1
  232. package/out/sqlite-utils.js +38 -0
  233. package/out/sqlite-utils.js.map +1 -1
  234. package/out/street-centroid-schema.d.ts +7 -2
  235. package/out/street-centroid-schema.d.ts.map +1 -1
  236. package/out/street-centroid-schema.js.map +1 -1
  237. package/out/street-centroid.d.ts.map +1 -1
  238. package/out/street-centroid.js +7 -7
  239. package/out/street-centroid.js.map +1 -1
  240. package/out/street-morphology-fst-builder.d.ts.map +1 -1
  241. package/out/street-morphology-fst-builder.js +5 -4
  242. package/out/street-morphology-fst-builder.js.map +1 -1
  243. package/out/street-normalize.d.ts +83 -9
  244. package/out/street-normalize.d.ts.map +1 -1
  245. package/out/street-normalize.js +177 -10
  246. package/out/street-normalize.js.map +1 -1
  247. package/out/street-segment-schema.d.ts +6 -2
  248. package/out/street-segment-schema.d.ts.map +1 -1
  249. package/out/street-segment-schema.js.map +1 -1
  250. package/out/types.d.ts +74 -1
  251. package/out/types.d.ts.map +1 -1
  252. package/out/unified-schema.d.ts +1 -1
  253. package/out/unified-schema.js +1 -1
  254. package/out/uprn-lookup.d.ts +85 -0
  255. package/out/uprn-lookup.d.ts.map +1 -0
  256. package/out/uprn-lookup.js +152 -0
  257. package/out/uprn-lookup.js.map +1 -0
  258. package/out/uprn-schema.d.ts +93 -0
  259. package/out/uprn-schema.d.ts.map +1 -0
  260. package/out/uprn-schema.js +78 -0
  261. package/out/uprn-schema.js.map +1 -0
  262. package/out/weights-overlay-linker.d.ts +141 -0
  263. package/out/weights-overlay-linker.d.ts.map +1 -0
  264. package/out/weights-overlay-linker.js +259 -0
  265. package/out/weights-overlay-linker.js.map +1 -0
  266. package/package.json +296 -16
  267. package/place-importance-schema.ts +402 -0
  268. package/poi-lookup.ts +12 -13
  269. package/poi-schema.ts +8 -3
  270. package/polygon-schema.ts +47 -0
  271. package/postal-city-alias-lookup.ts +1 -1
  272. package/postal-city-candidate-schema.ts +3 -1
  273. package/postcode-point-lookup.ts +1 -1
  274. package/primary-preference.ts +207 -0
  275. package/proximity-rerank.ts +120 -0
  276. package/region-keys.ts +144 -0
  277. package/reverse.ts +17 -16
  278. package/schema.ts +1 -1
  279. package/search-fetch.ts +256 -0
  280. package/sharding.ts +3 -3
  281. package/sqlite-convention-source.ts +1 -1
  282. package/sqlite-utils.ts +63 -1
  283. package/street-centroid-schema.ts +8 -2
  284. package/street-centroid.ts +13 -8
  285. package/street-morphology-fst-builder.ts +5 -4
  286. package/street-normalize.ts +254 -24
  287. package/street-segment-schema.ts +7 -2
  288. package/types.ts +74 -1
  289. package/unified-schema.ts +1 -1
  290. package/uprn-lookup.ts +210 -0
  291. package/uprn-schema.ts +124 -0
  292. package/weights-overlay-linker.ts +377 -0
  293. package/geo.ts +0 -121
  294. package/out/geo.d.ts +0 -74
  295. package/out/geo.d.ts.map +0 -1
  296. package/out/geo.js +0 -71
  297. package/out/geo.js.map +0 -1
package/uprn-lookup.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Node reader for `uprn.db` — the OS Open UPRN layer (`uprn-schema.ts`). Two probes:
7
+ *
8
+ * - **`coordinateOf(uprn)`**: rowid B-tree hit on the `uprn` integer PK.
9
+ * - **`nearestUPRN(lat, lon, radiusM)`**: bounded nearest-point search over the res-9 `h3_cell`
10
+ * index — ring-by-ring `gridDisk` expansion with chunked `IN` probes and haversine ranking.
11
+ * Rings stop as soon as geometry proves no unprobed cell could beat the best hit — a distance
12
+ * bound, not POILookup's row-count accumulation, so the early exit can never strand a nearer
13
+ * point in an unprobed ring.
14
+ *
15
+ * ## `null` is a claim, scoped by coverage
16
+ *
17
+ * OS designates Open UPRN complete for GB (every UPRN in AddressBase Premium with geometry), and
18
+ * the builder writes `layer_coverage` with basis `designated` for every cell the product touches.
19
+ * So a `null` from either probe inside a covered cell is evidence of absence — "no such published
20
+ * GB UPRN" / "no UPRN within the radius". Outside coverage (Northern Ireland, the Isle of Man, the
21
+ * Channel Islands, open water) it is UNKNOWN, per the meaning-of-zero rule — callers building
22
+ * negative evidence must consult `readLayerCoverage`, not this reader alone.
23
+ *
24
+ * `latLngToCell`/`gridDisk` come from `h3-js`; the 48-bit short-cell packing is
25
+ * `@mailwoman/spatial`'s `shortCellToInt` via `uprnFullCell` — never reimplemented here.
26
+ */
27
+
28
+ import { DatabaseSync } from "node:sqlite"
29
+
30
+ import { haversineKm, shortCellToInt, type H3Cell } from "@mailwoman/spatial"
31
+ import { gridDisk } from "h3-js"
32
+
33
+ import { allRows } from "./sqlite-utils.ts"
34
+ import { uprnFullCell } from "./uprn-schema.ts"
35
+
36
+ /**
37
+ * Conservative FLOOR on how much CENTRE distance one unit of res-9 GRID distance buys, metres. Adjacent centres sit √3
38
+ * × edge apart (avg edge 174.4 m → ≈302 m); the worst bearing across a ring costs a further ×0.866, and H3's projection
39
+ * distortion shrinks edges by well under the slack this leaves (the true worst is ≈217 m per grid step). Dividing a
40
+ * radius by this over-counts rings and can never miss a cell; multiplying a grid distance by it under-states reach and
41
+ * can never end the ring walk early.
42
+ */
43
+ const RES9_CENTER_SPACING_FLOOR_M = 150
44
+
45
+ /**
46
+ * Conservative CEILING on a res-9 cell's centre-to-vertex distance, metres (avg edge 174.4 m; distortion stays well
47
+ * under this). A point within `radiusM` of the query sits in a cell whose CENTRE is within `radiusM` + this.
48
+ */
49
+ const RES9_CELL_RADIUS_CEILING_M = 300
50
+
51
+ /**
52
+ * Hard ceiling on `radiusM`. Keeps the probe bounded (10 km → ~72 rings ≈ 15.8k cells ≈ 18 `IN` chunks); a caller who
53
+ * wants a wider search than "which property is this coordinate" has outgrown this reader and should say so loudly.
54
+ */
55
+ export const UPRN_MAX_NEAREST_RADIUS_M = 10_000
56
+
57
+ /**
58
+ * `IN`-list chunk size for the cell probe — far under SQLite's 32,766 bound-variable ceiling.
59
+ */
60
+ const CELL_PROBE_CHUNK = 900
61
+
62
+ export interface UPRNCoordinate {
63
+ latitude: number
64
+ longitude: number
65
+ }
66
+
67
+ export interface UPRNNearestHit {
68
+ uprn: number
69
+ latitude: number
70
+ longitude: number
71
+ /**
72
+ * Haversine distance from the query point, metres.
73
+ */
74
+ distanceM: number
75
+ }
76
+
77
+ export interface UPRNLookupOpts {
78
+ /**
79
+ * Path to a `uprn.db` built by `mailwoman`'s gazetteer pipeline. Opened read-only.
80
+ */
81
+ databasePath?: string
82
+ /**
83
+ * Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`.
84
+ */
85
+ database?: DatabaseSync
86
+ }
87
+
88
+ interface UPRNRow {
89
+ uprn: number
90
+ lat: number
91
+ lon: number
92
+ }
93
+
94
+ /**
95
+ * Node reader over `uprn.db`. `implements Disposable` so callers can `using lookup = new UPRNLookup(...)` — the same
96
+ * precedent as {@link POILookup}.
97
+ */
98
+ export class UPRNLookup implements Disposable {
99
+ #db: DatabaseSync
100
+ #ownsDB: boolean
101
+
102
+ /**
103
+ * `uprn` → its point (rowid-alias PK hit).
104
+ */
105
+ readonly #coordinateProbe: ReturnType<DatabaseSync["prepare"]>
106
+
107
+ constructor(opts: UPRNLookupOpts) {
108
+ if (opts.database) {
109
+ this.#db = opts.database
110
+ this.#ownsDB = false
111
+ } else if (opts.databasePath) {
112
+ this.#db = new DatabaseSync(opts.databasePath, { readOnly: true })
113
+ this.#ownsDB = true
114
+ } else {
115
+ throw new Error("UPRNLookup needs `databasePath` or `database`")
116
+ }
117
+
118
+ this.#coordinateProbe = this.#db.prepare("SELECT lat, lon FROM uprn WHERE uprn = ?")
119
+ }
120
+
121
+ /**
122
+ * The WGS84 point OS publishes for `uprn`, or `null` when the layer holds no such UPRN (see the module docstring for
123
+ * what that `null` claims).
124
+ */
125
+ coordinateOf(uprn: number): UPRNCoordinate | null {
126
+ const row = this.#coordinateProbe.get(uprn) as { lat: number; lon: number } | undefined
127
+
128
+ return row ? { latitude: row.lat, longitude: row.lon } : null
129
+ }
130
+
131
+ /**
132
+ * The single nearest UPRN within `radiusM` metres of the query point, or `null` when no UPRN lies inside the radius.
133
+ *
134
+ * Bounded two ways: `radiusM` is capped at {@link UPRN_MAX_NEAREST_RADIUS_M}, and rings expand outward only until no
135
+ * unprobed cell could beat the best hit found so far (or the radius, when nothing has been found). The stop rule is
136
+ * geometric — a cell at grid distance `g` holds no point nearer than `g` × spacing floor − cell radius, using the
137
+ * same conservative constants the reach math uses — so unlike POILookup's row-count accumulation there is no
138
+ * early-exit ambiguity: a break can never strand a nearer point in an unprobed ring. This is what keeps a
139
+ * capped-radius call over dense ground at milliseconds instead of a full-disk fetch (measured 6.4 s → 13 ms for a 10
140
+ * km radius over central London, 41.6M-row layer; an empty-sea miss at the cap runs the full expansion, 74 ms).
141
+ *
142
+ * @throws {RangeError} When `radiusM` is not a positive finite number, or exceeds the cap.
143
+ */
144
+ nearestUPRN(latitude: number, longitude: number, radiusM: number): UPRNNearestHit | null {
145
+ if (!Number.isFinite(radiusM) || radiusM <= 0) {
146
+ throw new RangeError(`nearestUPRN: radiusM must be a positive finite number, received ${radiusM}`)
147
+ }
148
+
149
+ if (radiusM > UPRN_MAX_NEAREST_RADIUS_M) {
150
+ throw new RangeError(`nearestUPRN: radiusM ${radiusM} exceeds the ${UPRN_MAX_NEAREST_RADIUS_M} m cap`)
151
+ }
152
+
153
+ const origin = uprnFullCell(latitude, longitude)
154
+ const seenCells = new Set<string>()
155
+ let best: UPRNNearestHit | null = null
156
+
157
+ // `ring` is H3 grid distance; the loop terminates because the break bound is at most radiusM, which the
158
+ // RangeError above caps.
159
+ for (let ring = 0; ; ring++) {
160
+ // A cell at grid distance `ring` holds no point nearer than this. Once it exceeds what could still
161
+ // win — the best hit so far, or the radius itself — further rings cannot improve the answer.
162
+ const closestPossibleM = ring * RES9_CENTER_SPACING_FLOOR_M - RES9_CELL_RADIUS_CEILING_M
163
+
164
+ if (closestPossibleM > Math.min(radiusM, best?.distanceM ?? radiusM)) break
165
+
166
+ // gridDisk(origin, ring) returns the WHOLE disk out to `ring`; diffing against what's already been
167
+ // probed derives just this ring's new cells (the POILookup pattern).
168
+ const diskCells = gridDisk(origin, ring) as string[]
169
+ const newCells: number[] = []
170
+
171
+ for (const cell of diskCells) {
172
+ if (!seenCells.has(cell)) {
173
+ seenCells.add(cell)
174
+ newCells.push(shortCellToInt(cell as H3Cell))
175
+ }
176
+ }
177
+
178
+ for (let i = 0; i < newCells.length; i += CELL_PROBE_CHUNK) {
179
+ const chunk = newCells.slice(i, i + CELL_PROBE_CHUNK)
180
+ const placeholders = chunk.map(() => "?").join(", ")
181
+
182
+ // Prepared fresh per chunk arity — a cold, per-call path, same posture as POILookup's batched hydration.
183
+ const rows = allRows<UPRNRow>(
184
+ this.#db.prepare(`SELECT uprn, lat, lon FROM uprn WHERE h3_cell IN (${placeholders})`),
185
+ ...chunk
186
+ )
187
+
188
+ for (const row of rows) {
189
+ const distanceM = haversineKm(latitude, longitude, row.lat, row.lon) * 1000
190
+
191
+ if (distanceM <= radiusM && (best === null || distanceM < best.distanceM)) {
192
+ best = { uprn: row.uprn, latitude: row.lat, longitude: row.lon, distanceM }
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ return best
199
+ }
200
+
201
+ close(): void {
202
+ if (this.#ownsDB) {
203
+ this.#db.close()
204
+ }
205
+ }
206
+
207
+ [Symbol.dispose](): void {
208
+ this.close()
209
+ }
210
+ }
package/uprn-schema.ts ADDED
@@ -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 `uprn.db` — the OS Open UPRN spatial layer: every GB Unique Property Reference
7
+ * Number with its WGS84 point, so mailwoman results can carry UPRN as an interoperability key
8
+ * beside our own `@mailwoman/address-id`. One rowid table keyed `uprn INTEGER PRIMARY KEY` (the
9
+ * rowid alias — the optimal shape for an integer-PK point table; `WITHOUT ROWID` buys nothing
10
+ * here), plus a secondary res-9 `h3_cell` index for the bounded nearest-point probe.
11
+ *
12
+ * ## Coordinates are OS's own WGS84 columns
13
+ *
14
+ * The source CSV publishes BOTH coordinate systems per row — OSGB36 eastings/northings AND WGS84
15
+ * `LATITUDE`/`LONGITUDE`. This layer stores OS's own lat/lon verbatim and never reconverts from
16
+ * eastings: `@mailwoman/spatial`'s `osgb36ToWGS84` is a 7-parameter Helmert with a measured p95 of
17
+ * 4.18 m, and re-deriving what the publisher already computed (with OSTN15, exactly) would replace
18
+ * their answer with a strictly worse one.
19
+ *
20
+ * ## Why `h3_cell` exists at all
21
+ *
22
+ * The layer contract requires every domain row to be addressable by at least one spine key —
23
+ * `writeLayerManifest` throws on a manifest that declares none — and UPRN is its own id space, not
24
+ * H3/WOF/address-id/street. The res-9 short cell (`shortCellToInt`, the same packing as poi.db and
25
+ * the OSM situs shards) is the spine that fits a point table, and its index doubles as the
26
+ * `nearestUPRN` ring probe.
27
+ *
28
+ * The DB also embeds the layer-contract tables from `@mailwoman/core/layers`; the builder
29
+ * (`packages/mailwoman/gazetteer-pipeline/uprn-layer.ts`) writes the manifest and per-res-6-cell
30
+ * coverage.
31
+ */
32
+
33
+ import type { LayerContractDatabase } from "@mailwoman/core/layers"
34
+ import { shortCellToInt, type H3Cell } from "@mailwoman/spatial"
35
+ import { latLngToCell } from "h3-js"
36
+ import type { Kysely } from "kysely"
37
+
38
+ /**
39
+ * Resolution the `uprn` table's `h3_cell` column is keyed at — the shared layer-spine resolution (poi.db, the OSM situs
40
+ * shards).
41
+ */
42
+ export const UPRN_H3_RESOLUTION = 9
43
+
44
+ /**
45
+ * Resolution of the layer's `layer_coverage` cells — coarse, per the contract (matches poi.db).
46
+ */
47
+ export const UPRN_COVERAGE_H3_RESOLUTION = 6
48
+
49
+ /**
50
+ * One UPRN point. `uprn` is the rowid alias, so the primary probe (`coordinateOf`) is a rowid B-tree hit.
51
+ */
52
+ export interface UPRNTable {
53
+ /**
54
+ * The Unique Property Reference Number — up to 12 digits, so always within `Number.MAX_SAFE_INTEGER`.
55
+ */
56
+ uprn: number
57
+ /**
58
+ * WGS84 latitude, as OS published it (never reconverted from eastings — see the module docstring).
59
+ */
60
+ lat: number
61
+ /**
62
+ * WGS84 longitude, as OS published it.
63
+ */
64
+ lon: number
65
+ /**
66
+ * 48-bit short H3 cell at {@link UPRN_H3_RESOLUTION} (`uprnH3Cell`) — the layer-contract spine key and the
67
+ * `nearestUPRN` probe index.
68
+ */
69
+ h3_cell: number
70
+ }
71
+
72
+ /**
73
+ * Build-provenance key/value pairs the fixed `layer_manifest` columns have no room for: quality-drop counts, the header
74
+ * as found, the upstream licence text verbatim (the Code-Point provenance discipline).
75
+ */
76
+ export interface UPRNMetaTable {
77
+ key: string
78
+ value: string
79
+ }
80
+
81
+ export interface UPRNDatabase extends LayerContractDatabase {
82
+ uprn: UPRNTable
83
+ uprn_meta: UPRNMetaTable
84
+ }
85
+
86
+ /**
87
+ * The full res-9 cell for a UPRN point — the ONE derivation both the builder and every consumer share, so a fixture
88
+ * built by a test and a row built by the real ingest can never disagree on which cell a coordinate keys to.
89
+ */
90
+ export function uprnFullCell(latitude: number, longitude: number): H3Cell {
91
+ return latLngToCell(latitude, longitude, UPRN_H3_RESOLUTION) as H3Cell
92
+ }
93
+
94
+ /**
95
+ * The `h3_cell` column value for a UPRN point: {@link uprnFullCell} packed to the shared 48-bit short-cell integer.
96
+ */
97
+ export function uprnH3Cell(latitude: number, longitude: number): number {
98
+ return shortCellToInt(uprnFullCell(latitude, longitude))
99
+ }
100
+
101
+ export async function createUPRNTable(db: Kysely<UPRNDatabase>): Promise<void> {
102
+ await db.schema
103
+ .createTable("uprn")
104
+ .addColumn("uprn", "integer", (c) => c.primaryKey())
105
+ .addColumn("lat", "real", (c) => c.notNull())
106
+ .addColumn("lon", "real", (c) => c.notNull())
107
+ .addColumn("h3_cell", "integer", (c) => c.notNull())
108
+ .execute()
109
+ }
110
+
111
+ export async function createUPRNMetaTable(db: Kysely<UPRNDatabase>): Promise<void> {
112
+ await db.schema
113
+ .createTable("uprn_meta")
114
+ .addColumn("key", "text", (c) => c.primaryKey())
115
+ .addColumn("value", "text", (c) => c.notNull())
116
+ .execute()
117
+ }
118
+
119
+ /**
120
+ * Secondary index for the `nearestUPRN` ring probe. Builders call this AFTER the bulk load (index-after-load).
121
+ */
122
+ export async function createUPRNIndexes(db: Kysely<UPRNDatabase>): Promise<void> {
123
+ await db.schema.createIndex("uprn_h3_cell").on("uprn").column("h3_cell").execute()
124
+ }