@mailwoman/resolver-wof-sqlite 9.1.0 → 9.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.
Files changed (501) hide show
  1. package/README.md +36 -20
  2. package/lib/address/index.ts +9 -0
  3. package/{address-point-interpolation.ts → lib/address/point-interpolation.ts} +40 -28
  4. package/{address-point-schema.ts → lib/address/point-schema.ts} +34 -8
  5. package/lib/address/point.ts +280 -0
  6. package/{ancestry-backfill.ts → lib/ancestry/backfill.ts} +14 -19
  7. package/{ancestry.ts → lib/ancestry/index.ts} +13 -8
  8. package/{build-candidate.ts → lib/build-candidate.ts} +243 -199
  9. package/{build-slim.ts → lib/build-slim.ts} +153 -168
  10. package/lib/candidate/alias-bags.ts +56 -0
  11. package/lib/candidate/ancestors-sidecar.ts +204 -0
  12. package/lib/candidate/country-display-names.ts +79 -0
  13. package/lib/candidate/extract-fold.ts +138 -0
  14. package/lib/candidate/name-roles.ts +238 -0
  15. package/lib/candidate/own-name.ts +146 -0
  16. package/lib/candidate/place-attrs.ts +44 -0
  17. package/lib/candidate-ancestors-schema.ts +195 -0
  18. package/{candidate-fts.ts → lib/candidate-fts.ts} +7 -5
  19. package/{candidate-importance.ts → lib/candidate-importance.ts} +54 -57
  20. package/lib/candidate-lookup.ts +976 -0
  21. package/{candidate-schema.ts → lib/candidate-schema.ts} +34 -6
  22. package/lib/candidate-scoring.ts +261 -0
  23. package/lib/capital-schema.ts +89 -0
  24. package/lib/capitals.ts +148 -0
  25. package/{coincident-roles.ts → lib/coincident-roles.ts} +75 -19
  26. package/{convention.ts → lib/convention/index.ts} +5 -5
  27. package/lib/convention/schema.ts +72 -0
  28. package/{coverage-manifest-schema.ts → lib/coverage-manifest-schema.ts} +15 -16
  29. package/lib/currency-backfill.ts +334 -0
  30. package/lib/env.ts +50 -0
  31. package/lib/exact-match.ts +113 -0
  32. package/{sharding.ts → lib/extracts.ts} +63 -60
  33. package/lib/fst/autocomplete.ts +176 -0
  34. package/{fst-builder.ts → lib/fst/builder.ts} +30 -27
  35. package/{fst-deserialize-web.ts → lib/fst/deserialize-web.ts} +27 -87
  36. package/lib/fst/format.ts +106 -0
  37. package/{fst-freshness.ts → lib/fst/freshness.ts} +42 -75
  38. package/lib/fst/index.ts +14 -0
  39. package/{fst-matcher.ts → lib/fst/matcher.ts} +1 -1
  40. package/{fst-serialize.ts → lib/fst/serialize.ts} +37 -115
  41. package/{fst-types.ts → lib/fst/types.ts} +4 -2
  42. package/{fts.ts → lib/fts/index.ts} +28 -23
  43. package/{fts-query.ts → lib/fts/query.ts} +2 -2
  44. package/{geonames-aliases.ts → lib/geonames/aliases.ts} +13 -13
  45. package/lib/geonames/index.ts +8 -0
  46. package/{geonames-postal.ts → lib/geonames/postal.ts} +16 -15
  47. package/{index.ts → lib/index.ts} +52 -48
  48. package/{interpolation.ts → lib/interpolation.ts} +137 -41
  49. package/lib/lookup.ts +861 -0
  50. package/{name-score.ts → lib/name-score.ts} +6 -4
  51. package/lib/nsul/index.ts +8 -0
  52. package/lib/nsul/lookup.ts +144 -0
  53. package/lib/nsul/schema.ts +130 -0
  54. package/{place-importance-schema.ts → lib/place-importance-schema.ts} +70 -22
  55. package/lib/poi/index.ts +8 -0
  56. package/{poi-lookup.ts → lib/poi/lookup.ts} +32 -39
  57. package/{poi-schema.ts → lib/poi/schema.ts} +10 -6
  58. package/lib/polygon-schema.ts +47 -0
  59. package/{postal-city-alias-lookup.ts → lib/postal/city-alias-lookup.ts} +15 -21
  60. package/{postal-city-candidate-schema.ts → lib/postal/city-candidate-schema.ts} +4 -2
  61. package/lib/postal/index.ts +9 -0
  62. package/{postcode-point-lookup.ts → lib/postcode-point-lookup.ts} +17 -15
  63. package/lib/primary-preference.ts +226 -0
  64. package/lib/proximity-rerank.ts +120 -0
  65. package/{ranking-weights.ts → lib/ranking-weights.ts} +30 -4
  66. package/lib/region-keys.ts +144 -0
  67. package/{reverse.ts → lib/reverse.ts} +45 -56
  68. package/{schema.ts → lib/schema.ts} +11 -1
  69. package/lib/search-fetch.ts +257 -0
  70. package/{sqlite-convention-source.ts → lib/sqlite-convention-source.ts} +9 -10
  71. package/lib/sqlite-utils.ts +83 -0
  72. package/{street-centroid-schema.ts → lib/street/centroid-schema.ts} +12 -6
  73. package/{street-centroid.ts → lib/street/centroid.ts} +26 -28
  74. package/lib/street/index.ts +13 -0
  75. package/{street-morphology-fst-builder.ts → lib/street/morphology-fst-builder.ts} +24 -14
  76. package/{street-morphology-fst-loader.ts → lib/street/morphology-fst-loader.ts} +14 -12
  77. package/{street-name-lookup.ts → lib/street/name-lookup.ts} +14 -30
  78. package/lib/street/normalize.ts +572 -0
  79. package/{street-segment-schema.ts → lib/street/segment-schema.ts} +13 -8
  80. package/{types.ts → lib/types.ts} +49 -9
  81. package/{unified-schema.ts → lib/unified-schema.ts} +21 -25
  82. package/lib/uprn/existence.ts +84 -0
  83. package/lib/uprn/index.ts +9 -0
  84. package/lib/uprn/lookup.ts +206 -0
  85. package/lib/uprn/schema.ts +124 -0
  86. package/lib/weights-overlay-linker.ts +878 -0
  87. package/out/address/index.d.ts +9 -0
  88. package/out/address/index.d.ts.map +1 -0
  89. package/out/address/index.js +9 -0
  90. package/out/address/index.js.map +1 -0
  91. package/out/{address-point-interpolation.d.ts → address/point-interpolation.d.ts} +11 -10
  92. package/out/address/point-interpolation.d.ts.map +1 -0
  93. package/out/{address-point-interpolation.js → address/point-interpolation.js} +29 -23
  94. package/out/address/point-interpolation.js.map +1 -0
  95. package/out/{address-point-schema.d.ts → address/point-schema.d.ts} +30 -10
  96. package/out/address/point-schema.d.ts.map +1 -0
  97. package/out/{address-point-schema.js → address/point-schema.js} +7 -3
  98. package/out/address/point-schema.js.map +1 -0
  99. package/out/{address-point.d.ts → address/point.d.ts} +16 -8
  100. package/out/address/point.d.ts.map +1 -0
  101. package/out/address/point.js +191 -0
  102. package/out/address/point.js.map +1 -0
  103. package/out/{ancestry-backfill.d.ts → ancestry/backfill.d.ts} +7 -5
  104. package/out/ancestry/backfill.d.ts.map +1 -0
  105. package/out/{ancestry-backfill.js → ancestry/backfill.js} +12 -16
  106. package/out/ancestry/backfill.js.map +1 -0
  107. package/out/{ancestry.d.ts → ancestry/index.d.ts} +6 -5
  108. package/out/ancestry/index.d.ts.map +1 -0
  109. package/out/{ancestry.js → ancestry/index.js} +7 -7
  110. package/out/ancestry/index.js.map +1 -0
  111. package/out/build-candidate.d.ts +88 -7
  112. package/out/build-candidate.d.ts.map +1 -1
  113. package/out/build-candidate.js +145 -149
  114. package/out/build-candidate.js.map +1 -1
  115. package/out/build-slim.d.ts +5 -5
  116. package/out/build-slim.d.ts.map +1 -1
  117. package/out/build-slim.js +127 -142
  118. package/out/build-slim.js.map +1 -1
  119. package/out/candidate/alias-bags.d.ts +19 -0
  120. package/out/candidate/alias-bags.d.ts.map +1 -0
  121. package/out/candidate/alias-bags.js +39 -0
  122. package/out/candidate/alias-bags.js.map +1 -0
  123. package/out/candidate/ancestors-sidecar.d.ts +32 -0
  124. package/out/candidate/ancestors-sidecar.d.ts.map +1 -0
  125. package/out/candidate/ancestors-sidecar.js +140 -0
  126. package/out/candidate/ancestors-sidecar.js.map +1 -0
  127. package/out/candidate/country-display-names.d.ts +35 -0
  128. package/out/candidate/country-display-names.d.ts.map +1 -0
  129. package/out/candidate/country-display-names.js +59 -0
  130. package/out/candidate/country-display-names.js.map +1 -0
  131. package/out/candidate/extract-fold.d.ts +32 -0
  132. package/out/candidate/extract-fold.d.ts.map +1 -0
  133. package/out/candidate/extract-fold.js +104 -0
  134. package/out/candidate/extract-fold.js.map +1 -0
  135. package/out/candidate/name-roles.d.ts +57 -0
  136. package/out/candidate/name-roles.d.ts.map +1 -0
  137. package/out/candidate/name-roles.js +166 -0
  138. package/out/candidate/name-roles.js.map +1 -0
  139. package/out/candidate/own-name.d.ts +50 -0
  140. package/out/candidate/own-name.d.ts.map +1 -0
  141. package/out/candidate/own-name.js +132 -0
  142. package/out/candidate/own-name.js.map +1 -0
  143. package/out/candidate/place-attrs.d.ts +43 -0
  144. package/out/candidate/place-attrs.d.ts.map +1 -0
  145. package/out/candidate/place-attrs.js +15 -0
  146. package/out/candidate/place-attrs.js.map +1 -0
  147. package/out/candidate-ancestors-schema.d.ts +150 -0
  148. package/out/candidate-ancestors-schema.d.ts.map +1 -0
  149. package/out/candidate-ancestors-schema.js +123 -0
  150. package/out/candidate-ancestors-schema.js.map +1 -0
  151. package/out/candidate-fts.d.ts +7 -5
  152. package/out/candidate-fts.d.ts.map +1 -1
  153. package/out/candidate-fts.js +5 -3
  154. package/out/candidate-fts.js.map +1 -1
  155. package/out/candidate-importance.d.ts +16 -16
  156. package/out/candidate-importance.d.ts.map +1 -1
  157. package/out/candidate-importance.js +47 -52
  158. package/out/candidate-importance.js.map +1 -1
  159. package/out/candidate-lookup.d.ts +31 -52
  160. package/out/candidate-lookup.d.ts.map +1 -1
  161. package/out/candidate-lookup.js +380 -172
  162. package/out/candidate-lookup.js.map +1 -1
  163. package/out/candidate-schema.d.ts +31 -7
  164. package/out/candidate-schema.d.ts.map +1 -1
  165. package/out/candidate-schema.js +3 -0
  166. package/out/candidate-schema.js.map +1 -1
  167. package/out/candidate-scoring.d.ts +34 -0
  168. package/out/candidate-scoring.d.ts.map +1 -0
  169. package/out/candidate-scoring.js +196 -0
  170. package/out/candidate-scoring.js.map +1 -0
  171. package/out/capital-schema.d.ts +51 -0
  172. package/out/capital-schema.d.ts.map +1 -0
  173. package/out/capital-schema.js +63 -0
  174. package/out/capital-schema.js.map +1 -0
  175. package/out/capitals.d.ts +69 -0
  176. package/out/capitals.d.ts.map +1 -0
  177. package/out/capitals.js +98 -0
  178. package/out/capitals.js.map +1 -0
  179. package/out/coincident-roles.d.ts +12 -4
  180. package/out/coincident-roles.d.ts.map +1 -1
  181. package/out/coincident-roles.js +43 -11
  182. package/out/coincident-roles.js.map +1 -1
  183. package/out/{convention.d.ts → convention/index.d.ts} +5 -5
  184. package/out/convention/index.d.ts.map +1 -0
  185. package/out/{convention.js → convention/index.js} +4 -4
  186. package/out/convention/index.js.map +1 -0
  187. package/out/convention/schema.d.ts +51 -0
  188. package/out/convention/schema.d.ts.map +1 -0
  189. package/out/convention/schema.js +34 -0
  190. package/out/convention/schema.js.map +1 -0
  191. package/out/coverage-manifest-schema.d.ts +8 -8
  192. package/out/coverage-manifest-schema.d.ts.map +1 -1
  193. package/out/coverage-manifest-schema.js +6 -10
  194. package/out/coverage-manifest-schema.js.map +1 -1
  195. package/out/currency-backfill.d.ts +90 -0
  196. package/out/currency-backfill.d.ts.map +1 -0
  197. package/out/currency-backfill.js +196 -0
  198. package/out/currency-backfill.js.map +1 -0
  199. package/out/env.d.ts +43 -0
  200. package/out/env.d.ts.map +1 -0
  201. package/out/env.js +48 -0
  202. package/out/env.js.map +1 -0
  203. package/out/exact-match.d.ts +25 -0
  204. package/out/exact-match.d.ts.map +1 -0
  205. package/out/exact-match.js +88 -0
  206. package/out/exact-match.js.map +1 -0
  207. package/out/extracts.d.ts +114 -0
  208. package/out/extracts.d.ts.map +1 -0
  209. package/out/{sharding.js → extracts.js} +38 -38
  210. package/out/extracts.js.map +1 -0
  211. package/out/{fst-autocomplete.d.ts → fst/autocomplete.d.ts} +13 -13
  212. package/out/fst/autocomplete.d.ts.map +1 -0
  213. package/out/fst/autocomplete.js +113 -0
  214. package/out/fst/autocomplete.js.map +1 -0
  215. package/out/{fst-builder.d.ts → fst/builder.d.ts} +5 -5
  216. package/out/fst/builder.d.ts.map +1 -0
  217. package/out/{fst-builder.js → fst/builder.js} +24 -24
  218. package/out/fst/builder.js.map +1 -0
  219. package/out/{fst-deserialize-web.d.ts → fst/deserialize-web.d.ts} +3 -3
  220. package/out/fst/deserialize-web.d.ts.map +1 -0
  221. package/out/{fst-deserialize-web.js → fst/deserialize-web.js} +10 -74
  222. package/out/fst/deserialize-web.js.map +1 -0
  223. package/out/fst/format.d.ts +80 -0
  224. package/out/fst/format.d.ts.map +1 -0
  225. package/out/fst/format.js +91 -0
  226. package/out/fst/format.js.map +1 -0
  227. package/out/{fst-freshness.d.ts → fst/freshness.d.ts} +11 -14
  228. package/out/fst/freshness.d.ts.map +1 -0
  229. package/out/{fst-freshness.js → fst/freshness.js} +42 -74
  230. package/out/fst/freshness.js.map +1 -0
  231. package/out/fst/index.d.ts +14 -0
  232. package/out/fst/index.d.ts.map +1 -0
  233. package/out/fst/index.js +14 -0
  234. package/out/fst/index.js.map +1 -0
  235. package/out/{fst-matcher.d.ts → fst/matcher.d.ts} +2 -2
  236. package/out/fst/matcher.d.ts.map +1 -0
  237. package/out/{fst-matcher.js → fst/matcher.js} +1 -1
  238. package/out/fst/matcher.js.map +1 -0
  239. package/out/{fst-serialize.d.ts → fst/serialize.d.ts} +5 -10
  240. package/out/fst/serialize.d.ts.map +1 -0
  241. package/out/{fst-serialize.js → fst/serialize.js} +18 -98
  242. package/out/fst/serialize.js.map +1 -0
  243. package/out/{fst-types.d.ts → fst/types.d.ts} +4 -3
  244. package/out/fst/types.d.ts.map +1 -0
  245. package/out/{fst-types.js → fst/types.js} +1 -1
  246. package/out/fst/types.js.map +1 -0
  247. package/out/{fts.d.ts → fts/index.d.ts} +20 -13
  248. package/out/fts/index.d.ts.map +1 -0
  249. package/out/{fts.js → fts/index.js} +20 -15
  250. package/out/fts/index.js.map +1 -0
  251. package/out/{fts-query.d.ts → fts/query.d.ts} +2 -2
  252. package/out/fts/query.d.ts.map +1 -0
  253. package/out/{fts-query.js → fts/query.js} +2 -2
  254. package/out/fts/query.js.map +1 -0
  255. package/out/{geonames-aliases.d.ts → geonames/aliases.d.ts} +8 -6
  256. package/out/geonames/aliases.d.ts.map +1 -0
  257. package/out/{geonames-aliases.js → geonames/aliases.js} +7 -7
  258. package/out/geonames/aliases.js.map +1 -0
  259. package/out/geonames/index.d.ts +8 -0
  260. package/out/geonames/index.d.ts.map +1 -0
  261. package/out/geonames/index.js +8 -0
  262. package/out/geonames/index.js.map +1 -0
  263. package/out/{geonames-postal.d.ts → geonames/postal.d.ts} +12 -10
  264. package/out/geonames/postal.d.ts.map +1 -0
  265. package/out/{geonames-postal.js → geonames/postal.js} +11 -11
  266. package/out/geonames/postal.js.map +1 -0
  267. package/out/index.d.ts +32 -31
  268. package/out/index.d.ts.map +1 -1
  269. package/out/index.js +24 -24
  270. package/out/index.js.map +1 -1
  271. package/out/interpolation.d.ts +18 -9
  272. package/out/interpolation.d.ts.map +1 -1
  273. package/out/interpolation.js +108 -36
  274. package/out/interpolation.js.map +1 -1
  275. package/out/lookup.d.ts +25 -25
  276. package/out/lookup.d.ts.map +1 -1
  277. package/out/lookup.js +174 -558
  278. package/out/lookup.js.map +1 -1
  279. package/out/name-score.d.ts +0 -10
  280. package/out/name-score.d.ts.map +1 -1
  281. package/out/name-score.js +6 -4
  282. package/out/name-score.js.map +1 -1
  283. package/out/nsul/index.d.ts +8 -0
  284. package/out/nsul/index.d.ts.map +1 -0
  285. package/out/nsul/index.js +8 -0
  286. package/out/nsul/index.js.map +1 -0
  287. package/out/nsul/lookup.d.ts +83 -0
  288. package/out/nsul/lookup.d.ts.map +1 -0
  289. package/out/nsul/lookup.js +80 -0
  290. package/out/nsul/lookup.js.map +1 -0
  291. package/out/nsul/schema.d.ts +100 -0
  292. package/out/nsul/schema.d.ts.map +1 -0
  293. package/out/nsul/schema.js +78 -0
  294. package/out/nsul/schema.js.map +1 -0
  295. package/out/place-importance-schema.d.ts +47 -10
  296. package/out/place-importance-schema.d.ts.map +1 -1
  297. package/out/place-importance-schema.js +56 -10
  298. package/out/place-importance-schema.js.map +1 -1
  299. package/out/poi/index.d.ts +8 -0
  300. package/out/poi/index.d.ts.map +1 -0
  301. package/out/poi/index.js +8 -0
  302. package/out/poi/index.js.map +1 -0
  303. package/out/{poi-lookup.d.ts → poi/lookup.d.ts} +8 -10
  304. package/out/poi/lookup.d.ts.map +1 -0
  305. package/out/{poi-lookup.js → poi/lookup.js} +24 -29
  306. package/out/poi/lookup.js.map +1 -0
  307. package/out/{poi-schema.d.ts → poi/schema.d.ts} +10 -6
  308. package/out/poi/schema.d.ts.map +1 -0
  309. package/out/{poi-schema.js → poi/schema.js} +1 -1
  310. package/out/poi/schema.js.map +1 -0
  311. package/out/polygon-schema.d.ts +37 -0
  312. package/out/polygon-schema.d.ts.map +1 -0
  313. package/out/polygon-schema.js +23 -0
  314. package/out/polygon-schema.js.map +1 -0
  315. package/out/{postal-city-alias-lookup.d.ts → postal/city-alias-lookup.d.ts} +7 -6
  316. package/out/postal/city-alias-lookup.d.ts.map +1 -0
  317. package/out/{postal-city-alias-lookup.js → postal/city-alias-lookup.js} +12 -16
  318. package/out/postal/city-alias-lookup.js.map +1 -0
  319. package/out/{postal-city-alias-schema.d.ts → postal/city-alias-schema.d.ts} +1 -1
  320. package/out/{postal-city-alias-schema.d.ts.map → postal/city-alias-schema.d.ts.map} +1 -1
  321. package/out/{postal-city-alias-schema.js → postal/city-alias-schema.js} +1 -1
  322. package/out/{postal-city-alias-schema.js.map → postal/city-alias-schema.js.map} +1 -1
  323. package/out/{postal-city-candidate-schema.d.ts → postal/city-candidate-schema.d.ts} +4 -3
  324. package/out/postal/city-candidate-schema.d.ts.map +1 -0
  325. package/out/{postal-city-candidate-schema.js → postal/city-candidate-schema.js} +2 -2
  326. package/out/{postal-city-candidate-schema.js.map → postal/city-candidate-schema.js.map} +1 -1
  327. package/out/postal/index.d.ts +9 -0
  328. package/out/postal/index.d.ts.map +1 -0
  329. package/out/postal/index.js +9 -0
  330. package/out/postal/index.js.map +1 -0
  331. package/out/postcode-point-lookup.d.ts +10 -10
  332. package/out/postcode-point-lookup.d.ts.map +1 -1
  333. package/out/postcode-point-lookup.js +13 -13
  334. package/out/postcode-point-lookup.js.map +1 -1
  335. package/out/primary-preference.d.ts +133 -0
  336. package/out/primary-preference.d.ts.map +1 -0
  337. package/out/primary-preference.js +146 -0
  338. package/out/primary-preference.js.map +1 -0
  339. package/out/proximity-rerank.d.ts +77 -0
  340. package/out/proximity-rerank.d.ts.map +1 -0
  341. package/out/proximity-rerank.js +86 -0
  342. package/out/proximity-rerank.js.map +1 -0
  343. package/out/ranking-weights.d.ts +15 -3
  344. package/out/ranking-weights.d.ts.map +1 -1
  345. package/out/ranking-weights.js +21 -3
  346. package/out/ranking-weights.js.map +1 -1
  347. package/out/region-keys.d.ts +47 -0
  348. package/out/region-keys.d.ts.map +1 -0
  349. package/out/region-keys.js +121 -0
  350. package/out/region-keys.js.map +1 -0
  351. package/out/reverse.d.ts +6 -6
  352. package/out/reverse.d.ts.map +1 -1
  353. package/out/reverse.js +23 -35
  354. package/out/reverse.js.map +1 -1
  355. package/out/schema.d.ts +10 -1
  356. package/out/schema.d.ts.map +1 -1
  357. package/out/schema.js.map +1 -1
  358. package/out/search-fetch.d.ts +59 -0
  359. package/out/search-fetch.d.ts.map +1 -0
  360. package/out/search-fetch.js +184 -0
  361. package/out/search-fetch.js.map +1 -0
  362. package/out/sqlite-convention-source.d.ts +7 -7
  363. package/out/sqlite-convention-source.d.ts.map +1 -1
  364. package/out/sqlite-convention-source.js +5 -5
  365. package/out/sqlite-convention-source.js.map +1 -1
  366. package/out/sqlite-utils.d.ts +24 -6
  367. package/out/sqlite-utils.d.ts.map +1 -1
  368. package/out/sqlite-utils.js +24 -7
  369. package/out/sqlite-utils.js.map +1 -1
  370. package/out/{street-centroid-schema.d.ts → street/centroid-schema.d.ts} +12 -7
  371. package/out/street/centroid-schema.d.ts.map +1 -0
  372. package/out/{street-centroid-schema.js → street/centroid-schema.js} +5 -5
  373. package/out/{street-centroid-schema.js.map → street/centroid-schema.js.map} +1 -1
  374. package/out/{street-centroid.d.ts → street/centroid.d.ts} +6 -6
  375. package/out/street/centroid.d.ts.map +1 -0
  376. package/out/{street-centroid.js → street/centroid.js} +18 -24
  377. package/out/street/centroid.js.map +1 -0
  378. package/out/street/index.d.ts +13 -0
  379. package/out/street/index.d.ts.map +1 -0
  380. package/out/street/index.js +13 -0
  381. package/out/street/index.js.map +1 -0
  382. package/out/{street-morphology-fst-builder.d.ts → street/morphology-fst-builder.d.ts} +4 -4
  383. package/out/street/morphology-fst-builder.d.ts.map +1 -0
  384. package/out/{street-morphology-fst-builder.js → street/morphology-fst-builder.js} +18 -13
  385. package/out/street/morphology-fst-builder.js.map +1 -0
  386. package/out/{street-morphology-fst-loader.d.ts → street/morphology-fst-loader.d.ts} +5 -5
  387. package/out/street/morphology-fst-loader.d.ts.map +1 -0
  388. package/out/{street-morphology-fst-loader.js → street/morphology-fst-loader.js} +11 -10
  389. package/out/street/morphology-fst-loader.js.map +1 -0
  390. package/out/{street-name-lookup.d.ts → street/name-lookup.d.ts} +4 -7
  391. package/out/street/name-lookup.d.ts.map +1 -0
  392. package/out/{street-name-lookup.js → street/name-lookup.js} +9 -23
  393. package/out/street/name-lookup.js.map +1 -0
  394. package/out/street/normalize.d.ts +179 -0
  395. package/out/street/normalize.d.ts.map +1 -0
  396. package/out/street/normalize.js +457 -0
  397. package/out/street/normalize.js.map +1 -0
  398. package/out/{street-segment-schema.d.ts → street/segment-schema.d.ts} +13 -9
  399. package/out/street/segment-schema.d.ts.map +1 -0
  400. package/out/{street-segment-schema.js → street/segment-schema.js} +4 -4
  401. package/out/{street-segment-schema.js.map → street/segment-schema.js.map} +1 -1
  402. package/out/types.d.ts +49 -9
  403. package/out/types.d.ts.map +1 -1
  404. package/out/types.js.map +1 -1
  405. package/out/unified-schema.d.ts +6 -5
  406. package/out/unified-schema.d.ts.map +1 -1
  407. package/out/unified-schema.js +16 -19
  408. package/out/unified-schema.js.map +1 -1
  409. package/out/uprn/existence.d.ts +53 -0
  410. package/out/uprn/existence.d.ts.map +1 -0
  411. package/out/uprn/existence.js +61 -0
  412. package/out/uprn/existence.js.map +1 -0
  413. package/out/uprn/index.d.ts +9 -0
  414. package/out/uprn/index.d.ts.map +1 -0
  415. package/out/uprn/index.js +9 -0
  416. package/out/uprn/index.js.map +1 -0
  417. package/out/uprn/lookup.d.ts +85 -0
  418. package/out/uprn/lookup.d.ts.map +1 -0
  419. package/out/uprn/lookup.js +149 -0
  420. package/out/uprn/lookup.js.map +1 -0
  421. package/out/uprn/schema.d.ts +93 -0
  422. package/out/uprn/schema.d.ts.map +1 -0
  423. package/out/uprn/schema.js +78 -0
  424. package/out/uprn/schema.js.map +1 -0
  425. package/out/weights-overlay-linker.d.ts +299 -0
  426. package/out/weights-overlay-linker.d.ts.map +1 -0
  427. package/out/weights-overlay-linker.js +511 -0
  428. package/out/weights-overlay-linker.js.map +1 -0
  429. package/package.json +622 -154
  430. package/address-point.ts +0 -125
  431. package/candidate-lookup.ts +0 -720
  432. package/fst-autocomplete.ts +0 -204
  433. package/geo.ts +0 -121
  434. package/lookup.ts +0 -1354
  435. package/out/address-point-interpolation.d.ts.map +0 -1
  436. package/out/address-point-interpolation.js.map +0 -1
  437. package/out/address-point-schema.d.ts.map +0 -1
  438. package/out/address-point-schema.js.map +0 -1
  439. package/out/address-point.d.ts.map +0 -1
  440. package/out/address-point.js +0 -84
  441. package/out/address-point.js.map +0 -1
  442. package/out/ancestry-backfill.d.ts.map +0 -1
  443. package/out/ancestry-backfill.js.map +0 -1
  444. package/out/ancestry.d.ts.map +0 -1
  445. package/out/ancestry.js.map +0 -1
  446. package/out/convention.d.ts.map +0 -1
  447. package/out/convention.js.map +0 -1
  448. package/out/fst-autocomplete.d.ts.map +0 -1
  449. package/out/fst-autocomplete.js +0 -130
  450. package/out/fst-autocomplete.js.map +0 -1
  451. package/out/fst-builder.d.ts.map +0 -1
  452. package/out/fst-builder.js.map +0 -1
  453. package/out/fst-deserialize-web.d.ts.map +0 -1
  454. package/out/fst-deserialize-web.js.map +0 -1
  455. package/out/fst-freshness.d.ts.map +0 -1
  456. package/out/fst-freshness.js.map +0 -1
  457. package/out/fst-matcher.d.ts.map +0 -1
  458. package/out/fst-matcher.js.map +0 -1
  459. package/out/fst-serialize.d.ts.map +0 -1
  460. package/out/fst-serialize.js.map +0 -1
  461. package/out/fst-types.d.ts.map +0 -1
  462. package/out/fst-types.js.map +0 -1
  463. package/out/fts-query.d.ts.map +0 -1
  464. package/out/fts-query.js.map +0 -1
  465. package/out/fts.d.ts.map +0 -1
  466. package/out/fts.js.map +0 -1
  467. package/out/geo.d.ts +0 -74
  468. package/out/geo.d.ts.map +0 -1
  469. package/out/geo.js +0 -71
  470. package/out/geo.js.map +0 -1
  471. package/out/geonames-aliases.d.ts.map +0 -1
  472. package/out/geonames-aliases.js.map +0 -1
  473. package/out/geonames-postal.d.ts.map +0 -1
  474. package/out/geonames-postal.js.map +0 -1
  475. package/out/poi-lookup.d.ts.map +0 -1
  476. package/out/poi-lookup.js.map +0 -1
  477. package/out/poi-schema.d.ts.map +0 -1
  478. package/out/poi-schema.js.map +0 -1
  479. package/out/postal-city-alias-lookup.d.ts.map +0 -1
  480. package/out/postal-city-alias-lookup.js.map +0 -1
  481. package/out/postal-city-candidate-schema.d.ts.map +0 -1
  482. package/out/sharding.d.ts +0 -114
  483. package/out/sharding.d.ts.map +0 -1
  484. package/out/sharding.js.map +0 -1
  485. package/out/street-centroid-schema.d.ts.map +0 -1
  486. package/out/street-centroid.d.ts.map +0 -1
  487. package/out/street-centroid.js.map +0 -1
  488. package/out/street-morphology-fst-builder.d.ts.map +0 -1
  489. package/out/street-morphology-fst-builder.js.map +0 -1
  490. package/out/street-morphology-fst-loader.d.ts.map +0 -1
  491. package/out/street-morphology-fst-loader.js.map +0 -1
  492. package/out/street-name-lookup.d.ts.map +0 -1
  493. package/out/street-name-lookup.js.map +0 -1
  494. package/out/street-normalize.d.ts +0 -95
  495. package/out/street-normalize.d.ts.map +0 -1
  496. package/out/street-normalize.js +0 -272
  497. package/out/street-normalize.js.map +0 -1
  498. package/out/street-segment-schema.d.ts.map +0 -1
  499. package/sqlite-utils.ts +0 -46
  500. package/street-normalize.ts +0 -318
  501. /package/{postal-city-alias-schema.ts → lib/postal/city-alias-schema.ts} +0 -0
package/lib/lookup.ts ADDED
@@ -0,0 +1,861 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `WOFSQLitePlaceLookup` — the resolver implementation backed by `node:sqlite` + a Kysely-typed
7
+ * query layer where the queries are non-trivial, and raw SQL where they aren't (FTS5 MATCH, the
8
+ * FTS index build).
9
+ *
10
+ * See `docs/plan/phases/PHASE_4_2_wof_sqlite.md` for the design rationale.
11
+ */
12
+
13
+ import { expandPlacetypeFilter, type Ancestor, type CoincidentLocality } from "@mailwoman/resolver"
14
+ import { haversineKm } from "@mailwoman/spatial"
15
+ import type { SQLInputValue } from "@mailwoman/sqlite/client"
16
+ import { DatabaseClient } from "@mailwoman/sqlite/client"
17
+
18
+ import { ancestorLineage } from "#ancestry/index"
19
+ import { candidateFromSearchRow, rankCandidates } from "#candidate-scoring"
20
+ import { loadCoincidentLocalities } from "#coincident-roles"
21
+ import {
22
+ ADDRESS_CONVENTION_TABLE,
23
+ resolveConvention,
24
+ SeedConventionSource,
25
+ type Convention,
26
+ type ConventionSource,
27
+ type ResolvedConvention,
28
+ type Strategy,
29
+ } from "#convention/index"
30
+ import {
31
+ pickExtractForPlacetype,
32
+ pickExtractsForPlacetype,
33
+ resolveExtracts,
34
+ type ResolvedExtract,
35
+ type ExtractConfig,
36
+ } from "#extracts"
37
+ import {
38
+ buildPlaceSearchFTS,
39
+ PLACE_BBOX_TABLE,
40
+ PLACE_POPULATION_TABLE,
41
+ PLACE_SEARCH_TABLE,
42
+ placeBboxExists,
43
+ placePopulationExists,
44
+ placeSearchFTSExists,
45
+ } from "#fts/index"
46
+ import { normalizePlacetypes, sanitizeFTSQuery } from "#fts/query"
47
+ import { cfNormalize, softNameScore } from "#name-score"
48
+ import { encyclopedicClauses } from "#place-importance-schema"
49
+ import type { WOFPostalCityAliasLookup } from "#postal/city-alias-lookup"
50
+ import { DEFAULT_WEIGHTS, populationScaleTerm, type RankingWeights } from "#ranking-weights"
51
+ import type { WOFDatabase } from "#schema"
52
+ import { fetchSearchRows, type RawSearchRow } from "#search-fetch"
53
+ import { SqliteConventionSource } from "#sqlite-convention-source"
54
+ import { allRows } from "#sqlite-utils"
55
+ import type { FindPlaceQuery, PlaceCandidate, PlaceLookup, WOFPlacetype } from "#types"
56
+
57
+ export interface WOFSQLitePlaceLookupOpts {
58
+ /**
59
+ * Path to the WOF SQLite distribution on disk. Mutually exclusive with `database`.
60
+ *
61
+ * **Single string** — opens that one DB as the main extract.
62
+ *
63
+ * **Array** — opens the first entry as main, then ATTACHes each subsequent entry as a separate SQLite schema. Schema
64
+ * names are derived from the filename (`whosonfirst-data-postalcode- us-latest.db` → `postalcode_us`); override with
65
+ * `ExtractConfig.schemaName` when the filename doesn't follow WOF convention. See `extracts.ts` for the derivation
66
+ * rules.
67
+ *
68
+ * Routing: queries with a `placetype` matching a extract's name (or explicit `placetypes` hint) are sent to that
69
+ * extract; everything else hits main. Cross-extract UNION is NOT done — BM25 isn't comparable across
70
+ * separately-indexed corpora.
71
+ */
72
+ databasePath?: string | ReadonlyArray<string | ExtractConfig>
73
+ /**
74
+ * Pre-opened connection — primarily for tests against an inline fixture DB. Mutually exclusive with `databasePath`.
75
+ * Multi-extract requires `databasePath` (so the lookup owns the ATTACH).
76
+ */
77
+ database?: DatabaseClient<WOFDatabase>
78
+ /**
79
+ * If true, build the FTS5 `place_search` virtual table on construction if it doesn't already exist. The upstream WOF
80
+ * distribution does NOT ship FTS5, so callers either set this once on first open or pre-build it via the
81
+ * operator-side CLI documented in the README. Default false — the resolver assumes the index already exists and
82
+ * errors loudly if it doesn't.
83
+ *
84
+ * With multi-extract, `buildFTS: true` builds the index on the **main** extract only. Other extracts must be
85
+ * pre-built via `mailwoman gazetteer build fts` — operator script for predictable cost.
86
+ */
87
+ buildFTS?: boolean
88
+ /**
89
+ * Geographic Rule Engine convention source (Direction E, #289). Per-WOF-polygon resolution profiles, either as a
90
+ * ready `ConventionSource` or a plain `{ wofID: Convention }` seed map. Default empty — every query rides
91
+ * `WORLD_DEFAULT` (the EU coordinate-first behavior). JP/KR/TW add rows; #290 wires a build-from-source sqlite-backed
92
+ * source here.
93
+ */
94
+ conventions?: ConventionSource | Record<number, Convention>
95
+ /**
96
+ * Opt-in postal-city alias reader (#475). When supplied, the coordinate-first locality scorer treats an observed
97
+ * `postal_city` ("Antioch", postcode 37013) as a name-match alias for the geographic locality the postcode sits in
98
+ * ("Nashville"), recovering the chronic postal-vs- geographic-city mismatch. Absent (the default), the resolver is
99
+ * byte-identical — every alias code path is conditioned on this being non-null, so an unprovided reader changes no
100
+ * score.
101
+ */
102
+ postalCityAliases?: WOFPostalCityAliasLookup
103
+ }
104
+
105
+ /**
106
+ * The coordinate-first candidate table (scripts/build-postcode-locality.ts): postcode → containing
107
+ *
108
+ * - Nearby localities with WOF alt-name aliases.
109
+ */
110
+ /**
111
+ * The placetypes `pickExtractsForPlacetype`'s substring rule can route by name. Not every WOF placetype — only the ones
112
+ * a purpose-built extract is ever named for — so the diagnostic below can say "this name routes nowhere" without
113
+ * claiming to enumerate the gazetteer.
114
+ */
115
+ const KNOWN_ROUTED_PLACETYPES: ReadonlyArray<string> = [
116
+ "postalcode",
117
+ "locality",
118
+ "region",
119
+ "county",
120
+ "country",
121
+ "venue",
122
+ ]
123
+
124
+ const POSTCODE_LOCALITY_TABLE = "postcode_locality"
125
+
126
+ /**
127
+ * Tunables for the coordinate-first locality soft-score `Score = pc·S_pc + name·S_name + pop·S_pop` (each S in [0,1]).
128
+ * The pc/name/pop WEIGHTS now come from the resolved convention's `scoringWeights` (`WORLD_DEFAULT` = 0.6/0.3/0.1 — the
129
+ * EU values), so a locale can retune them as data. PC_DECAY_KM sets how fast S_pc falls with distance.
130
+ */
131
+ const CF_PC_DECAY_KM = 8
132
+ /**
133
+ * The chosen locality must be within this distance of the postcode's containing locality, else the postcode and the
134
+ * parsed city name are judged to disagree (a transposed / wrong-for-the-city postcode) and the `mismatch` flag fires.
135
+ * Generous enough that a city-state Ortsteil (~15km from the city centroid) and an abutting town (~few km) are NOT
136
+ * flagged, tight enough to catch a wrong city (hundreds of km).
137
+ */
138
+ const CF_MISMATCH_KM = 50
139
+
140
+ export class WOFSQLitePlaceLookup implements PlaceLookup, Disposable {
141
+ readonly #db: DatabaseClient<WOFDatabase>
142
+ /**
143
+ * Resources this instance opened. A connection handed in by a caller is NOT in here, so disposal cannot reach it —
144
+ * ownership is membership rather than a flag a later branch has to check.
145
+ */
146
+ readonly #resources = new DisposableStack()
147
+ readonly #weights: RankingWeights
148
+ /**
149
+ * Cached at construction so we don't `sqlite_master` query on every findPlace call. Bbox + near- with-radius queries
150
+ * fall back to no-filter when this is false, preserving compatibility with DBs that were FTS-built before the R*Tree
151
+ * shipped.
152
+ *
153
+ * Per-extract: a extract is only considered to have the bbox index if its own R*Tree table exists.
154
+ */
155
+ readonly #hasBboxIndex: Map<string, boolean>
156
+ /**
157
+ * Per-extract probe for the `place_population` aux table. When false, the LEFT JOIN is omitted from the SELECT and
158
+ * population boost is 0 for every row — preserves compatibility with DBs built before this feature shipped.
159
+ */
160
+ readonly #hasPopulationIndex: Map<string, boolean>
161
+ /**
162
+ * Per-extract SELECT term + LEFT JOIN for the two-score split's `encyclopedic` carry (ROAD_TO_V9 §2 R1), probed and
163
+ * built once at construction. Degrades to `NULL AS encyclopedic` with no join on a pre-split extract — every shipped
164
+ * extract today. See {@link encyclopedicClauses} for why the probe is a column and not a table.
165
+ */
166
+ readonly #encyclopedicClauses: Map<string, { select: string; join: string }>
167
+ /**
168
+ * Per-extract probe for the `postcode_locality` table (the coordinate-first candidate table, built by
169
+ * scripts/build-postcode-locality.ts). Cached at construction; null'd out when absent so the coord-first path
170
+ * silently no-ops on a deployment that didn't ship the table.
171
+ */
172
+ readonly #postcodeLocalityExtract: string | null
173
+ /**
174
+ * Resolved extract list. Always at least one entry; first is `main`. Multi-extract adds extras with their own derived
175
+ * (or override) schema names.
176
+ */
177
+ readonly #extracts: ResolvedExtract[]
178
+ /**
179
+ * #920: per-schema probed country sets for country-aware extract routing (non-main extracts only).
180
+ */
181
+ readonly #extractCountries: Map<string, ReadonlySet<string>>
182
+ /**
183
+ * The Geographic Rule Engine (Direction E, #289). `#conventionSource` supplies per-WOF-polygon resolution profiles;
184
+ * `#strategies` is the named-primitive registry the merged convention dispatches. Empty source → every query resolves
185
+ * to `WORLD_DEFAULT` → byte-identical to the pre-engine coordinate-first path. `#countryWOFIdCache` memoizes the
186
+ * country-code → country-WOF-id lookup that seeds the convention ancestor chain (one query per country, then
187
+ * cached).
188
+ */
189
+ readonly #conventionSource: ConventionSource
190
+ readonly #strategies: Map<string, Strategy>
191
+ readonly #countryWOFIdCache = new Map<string, number | null>()
192
+ /**
193
+ * Strategy names already warned about — so an unknown name surfaces once, not once per query.
194
+ */
195
+ readonly #warnedUnknownStrategies = new Set<string>()
196
+ /**
197
+ * Lazily-built `admin_id → coincident localities` map from the #403 relation (null until first use).
198
+ */
199
+ #coincidentRolesCache: Map<number, CoincidentLocality[]> | null = null
200
+ /**
201
+ * Per-id memoized ancestor lineages (#404) — a hot chain is queried once.
202
+ */
203
+ readonly #ancestorsCache = new Map<number, Ancestor[]>()
204
+ /**
205
+ * Opt-in postal-city alias reader (#475). `null` unless `opts.postalCityAliases` was supplied — every alias code path
206
+ * is conditioned on this, so the default resolver is byte-identical.
207
+ */
208
+ readonly #postalCityAliases: WOFPostalCityAliasLookup | null
209
+
210
+ constructor(opts: WOFSQLitePlaceLookupOpts, weights?: Partial<RankingWeights>) {
211
+ if (opts.database && opts.databasePath) {
212
+ throw new Error("WOFSQLitePlaceLookup: pass either `database` or `databasePath`, not both")
213
+ }
214
+
215
+ if (!opts.database && !opts.databasePath) {
216
+ throw new Error("WOFSQLitePlaceLookup: one of `database` or `databasePath` is required")
217
+ }
218
+
219
+ if (opts.database) {
220
+ this.#db = opts.database
221
+ this.#extracts = [{ path: ":memory:", schemaName: "main", placetypes: [] }]
222
+ } else {
223
+ const extracts = resolveExtracts(opts.databasePath!)
224
+ this.#extracts = extracts
225
+ // Read-only by default — shipped gazetteer extracts are sealed 0444 and Docker `:ro` mounts
226
+ // forbid write-mode opens, so a writable open fails there. The only code path that writes to
227
+ // the main extract is `#ensureFTS()` (FTS5 index build), conditioned on `opts.buildFTS`; open writable
228
+ // ONLY when that build was explicitly requested. Every read query (FTS5 MATCH, the aux-table
229
+ // SELECTs, ATTACH, and the `busy_timeout` PRAGMA) works read-only. See the docker read-only
230
+ // mount limitation (#1213).
231
+ this.#db = this.#resources.use(new DatabaseClient<WOFDatabase>(extracts[0]!.path, { readOnly: !opts.buildFTS }))
232
+
233
+ // ATTACH each non-main extract. Schema names were validated by resolveExtracts, so safe to
234
+ // interpolate directly (SQLite ATTACH doesn't accept parameters for the schema name).
235
+ for (const s of extracts.slice(1)) {
236
+ this.#db.exec(`ATTACH DATABASE '${s.path.replaceAll("'", "''")}' AS ${s.schemaName}`)
237
+ }
238
+ }
239
+
240
+ // node:sqlite has no .pragma() helper; pragmas are executed as plain SQL.
241
+ this.#db.exec("PRAGMA busy_timeout = 5000")
242
+
243
+ if (opts.buildFTS) {
244
+ this.#ensureFTS()
245
+ } else {
246
+ this.#assertFTSExists()
247
+ }
248
+
249
+ this.#weights = { ...DEFAULT_WEIGHTS, ...weights }
250
+
251
+ // Probe each extract's aux-table presence — driven by per-extract table existence in
252
+ // sqlite_master. Cached at construction so findPlace doesn't query sqlite_master per call.
253
+ this.#hasBboxIndex = new Map()
254
+ this.#hasPopulationIndex = new Map()
255
+ this.#encyclopedicClauses = new Map()
256
+
257
+ for (const s of this.#extracts) {
258
+ this.#hasBboxIndex.set(s.schemaName, this.#extractHasTable(s.schemaName, PLACE_BBOX_TABLE))
259
+ this.#hasPopulationIndex.set(s.schemaName, this.#extractHasTable(s.schemaName, PLACE_POPULATION_TABLE))
260
+ this.#encyclopedicClauses.set(s.schemaName, encyclopedicClauses(this.#db, s.schemaName))
261
+ }
262
+
263
+ // Every lookup path here reaches `place_search`, and a extract without it fails in one of two ways
264
+ // that are both hard to read: an unroutable name returns zero hits (indistinguishable from "this
265
+ // country has no places") and a routable one throws mid-query from deep inside a SELECT. The
266
+ // unroutable half is the worse of the two — a extract reaches routing only through the name
267
+ // `deriveSchemaName` derives from its FILENAME, so a file spelled one letter off the placetype it
268
+ // serves answers with nothing while holding every row that was asked for.
269
+ //
270
+ // Two independent things bring a extract under the guard, and it needs both. Carrying `spr` is a
271
+ // CLAIM to be a place extract. Carrying a name that routes is an INVITATION to be queried as one, and
272
+ // it is made by the filename alone — so a database with no tables at all still gets picked, still
273
+ // answers no query, and still dies inside a SELECT. Testing only the claim lets an empty or
274
+ // truncated file past construction; testing only the name would exempt a correctly-named build
275
+ // input. A extract needs to fail neither test to be exempt.
276
+ //
277
+ // Exempt by design: `postcode-locality-<cc>.db` carries a relation table and nothing else, matches
278
+ // no routed placetype, and is part of the documented default extract list.
279
+ for (const s of this.#extracts) {
280
+ if (s.schemaName === "main") continue
281
+
282
+ const routes = KNOWN_ROUTED_PLACETYPES.some(
283
+ (pt) => s.schemaName === pt || s.schemaName.startsWith(`${pt}_`) || s.schemaName.endsWith(`_${pt}`)
284
+ )
285
+
286
+ const claimsPlaceExtract = this.#extractHasTable(s.schemaName, "spr")
287
+
288
+ if (!routes && !claimsPlaceExtract) continue
289
+
290
+ if (this.#extractHasTable(s.schemaName, PLACE_SEARCH_TABLE)) continue
291
+
292
+ throw new Error(
293
+ `WOFSQLitePlaceLookup: ${s.path} ` +
294
+ (claimsPlaceExtract
295
+ ? `carries "spr" but no "${PLACE_SEARCH_TABLE}" table, so it cannot serve a lookup.`
296
+ : `is named for a routed placetype but carries neither "spr" nor "${PLACE_SEARCH_TABLE}", so every ` +
297
+ `query routed to it would die mid-SELECT. An empty or truncated file reads exactly like this.`) +
298
+ ` Build it with the FTS index, or leave it out — it is usable as a BUILD input either way.` +
299
+ (routes
300
+ ? ""
301
+ : ` Its schema name "${s.schemaName}" also matches no routed placetype (${KNOWN_ROUTED_PLACETYPES.join(", ")}), ` +
302
+ `so it would never have been queried even with the table — check the filename's spelling.`)
303
+ )
304
+ }
305
+
306
+ // #920 country-aware extract routing: probe each NON-MAIN extract's country set once at
307
+ // construction (they're small, purpose-built extracts — postcode/locality slices; main is the
308
+ // multi-GB admin DB and is the fallback anyway, so it is deliberately NOT scanned). Feeds
309
+ // pickExtractForPlacetype so two postcode extracts (postalcode-us + postalcode-geonames-tail)
310
+ // route by the query's country instead of first-match starving the second extract.
311
+ this.#extractCountries = new Map()
312
+
313
+ for (const sh of this.#extracts) {
314
+ if (sh.schemaName === "main") continue
315
+
316
+ try {
317
+ const rows = this.#db
318
+ .prepare(`SELECT DISTINCT country FROM ${sh.schemaName}.spr WHERE country != ''`)
319
+ .all() as Array<{ country: string }>
320
+
321
+ this.#extractCountries.set(sh.schemaName, new Set(rows.map((r) => r.country)))
322
+ } catch {
323
+ // A extract without spr (or an attach oddity) just doesn't participate in country routing.
324
+ }
325
+ }
326
+
327
+ // The postcode_locality table can live on any attached extract (typically its own
328
+ // `postcode-locality-<cc>.db`). Find the first extract that has it; null = coord-first disabled.
329
+ this.#postcodeLocalityExtract =
330
+ this.#extracts.find((s) => this.#extractHasTable(s.schemaName, POSTCODE_LOCALITY_TABLE))?.schemaName ?? null
331
+
332
+ // Opt-in postal-city alias reader (#475). Construction-time present-or-not is the check: null
333
+ // keeps the coordinate-first scorer byte-identical to pre-#475.
334
+ this.#postalCityAliases = opts.postalCityAliases ?? null
335
+
336
+ // The Geographic Rule Engine convention source. Precedence: an explicit `opts.conventions`
337
+ // (a ready source or a seed map) wins; else the build-from-source convention asset if one is
338
+ // attached (auto-detected, like the postcode_locality extract — adding conventions.db to
339
+ // databasePath enables it; queried on demand, not paged into memory); else empty, so EU rides
340
+ // WORLD_DEFAULT. The registry binds strategy NAMES to the SQL-bound primitives — adding a
341
+ // strategy is registering it here.
342
+ const conventionExtract =
343
+ this.#extracts.find((s) => this.#extractHasTable(s.schemaName, ADDRESS_CONVENTION_TABLE))?.schemaName ?? null
344
+
345
+ this.#conventionSource = opts.conventions
346
+ ? "get" in opts.conventions && typeof opts.conventions.get === "function"
347
+ ? opts.conventions
348
+ : new SeedConventionSource(opts.conventions as Record<number, Convention>)
349
+ : conventionExtract
350
+ ? new SqliteConventionSource(this.#db, conventionExtract)
351
+ : new SeedConventionSource()
352
+
353
+ this.#strategies = new Map<string, Strategy>([
354
+ ["postcode_area_resolution", (q, c) => this.#postcodeAreaResolution(q, c)],
355
+ ["fallback_fuzzy_name_match", (q) => this.#fuzzyNameMatch(q)],
356
+ ])
357
+ }
358
+
359
+ #extractHasTable(schemaName: string, tableName: string): boolean {
360
+ // For main, the existing helpers work directly. For attached extracts we have to ask via the
361
+ // schema-qualified `sqlite_master` view.
362
+ if (schemaName === "main") {
363
+ if (tableName === PLACE_BBOX_TABLE) return placeBboxExists(this.#db)
364
+
365
+ if (tableName === PLACE_POPULATION_TABLE) return placePopulationExists(this.#db)
366
+ }
367
+
368
+ const row = this.#db
369
+ .prepare(`SELECT name FROM ${schemaName}.sqlite_master WHERE type = 'table' AND name = ?`)
370
+ .get(tableName) as { name: string } | undefined
371
+
372
+ return Boolean(row)
373
+ }
374
+
375
+ async findPlace(query: FindPlaceQuery): Promise<PlaceCandidate[]> {
376
+ // Geographic Rule Engine dispatch (#289). Resolve the effective convention for this query
377
+ // (WORLD_DEFAULT for the EU locales — the seed source is empty) and run its candidate strategies
378
+ // in order; the first to return a non-null result wins. The default list,
379
+ // [postcode_area_resolution, fallback_fuzzy_name_match], reproduces the pre-engine coordinate-
380
+ // first → FTS fall-through exactly. Unknown strategy names are skipped, so a convention may name
381
+ // a primitive a future phase will register.
382
+ const convention = this.#conventionFor(query)
383
+
384
+ let outcome: PlaceCandidate[] = []
385
+
386
+ for (const name of convention.candidateStrategies) {
387
+ const strategy = this.#strategies.get(name)
388
+
389
+ if (!strategy) {
390
+ this.#warnUnknownStrategy(name)
391
+
392
+ continue
393
+ }
394
+
395
+ const result = await strategy(query, convention)
396
+
397
+ if (result !== null) {
398
+ outcome = result
399
+
400
+ break
401
+ }
402
+ }
403
+
404
+ if (outcome.length) return outcome
405
+
406
+ // #924: NL postcode retry ladder. The WOF NL postalcode repo stores full codes UNSPACED
407
+ // ('1012LG') plus 4-digit stems ('1012'), while Dutch addresses carry the spaced form
408
+ // ('1012 LG') — two FTS tokens that can never match the one-token doc (the #920 name law,
409
+ // resurfacing in a WOF-built extract). On a postcode-typed NL-shape miss, retry ONCE with the
410
+ // whitespace-joined form (block-level precision when the full-code row exists), then the
411
+ // 4-digit stem (area-level). Country-restricted to NL — the same digits+letters shape elsewhere
412
+ // must not silently coarsen to a different system's code. Each retry only fires when its
413
+ // text differs from the current one, so the ladder terminates by construction.
414
+ if (
415
+ query.country?.toUpperCase() === "NL" &&
416
+ (normalizePlacetypes(query.placetype)?.includes("postalcode") ?? false) &&
417
+ /^\d{4}\s?[A-Za-z]{2}$/.test(query.text.trim())
418
+ ) {
419
+ const trimmed = query.text.trim()
420
+ const joined = trimmed.replaceAll(/\s+/g, "")
421
+
422
+ if (joined !== trimmed) {
423
+ const full = await this.findPlace({ ...query, text: joined })
424
+
425
+ if (full.length) return full
426
+ }
427
+
428
+ const stem = trimmed.slice(0, 4)
429
+
430
+ if (stem !== trimmed) return this.findPlace({ ...query, text: stem })
431
+ }
432
+
433
+ return outcome
434
+ }
435
+
436
+ /**
437
+ * Dual-role localities coincident with an admin id, from the precomputed `coincident_roles` relation (#403). Backs
438
+ * {@link ResolveOpts.hierarchyCompletion} (#405): O(1) once the relation is loaded. Returns `[]` when the relation
439
+ * table is absent (older DB) or the admin isn't a dual-role place, so completion degrades gracefully. The relation +
440
+ * `spr` join is loaded once and memoized.
441
+ */
442
+ coincidentLocalitiesFor(adminID: number | string): CoincidentLocality[] {
443
+ const id = typeof adminID === "number" ? adminID : Number(adminID)
444
+
445
+ if (!Number.isFinite(id)) return []
446
+
447
+ if (!this.#coincidentRolesCache) {
448
+ this.#coincidentRolesCache = loadCoincidentLocalities(this.#db)
449
+ }
450
+
451
+ return this.#coincidentRolesCache.get(id) ?? []
452
+ }
453
+
454
+ /**
455
+ * The ancestor lineage of a place — its containment chain joined with `spr` for canonical names, ordered
456
+ * NEAREST-FIRST (localadmin → county → region → … → country). Backs {@link ResolveOpts.includeAncestors} (#404). Self
457
+ * is excluded; memoized per id. Returns `[]` when the place has no recorded ancestry.
458
+ *
459
+ * The walk itself lives in `ancestry.ts` (shared with the reverse geocoder, #484); the ordering is its
460
+ * `PLACETYPE_DEPTH` table — same ranking as the previous inline SQL CASE, extended below `localadmin` so
461
+ * locality/neighbourhood ancestors order correctly instead of sorting last.
462
+ */
463
+ ancestors(id: number | string): Ancestor[] {
464
+ const pid = typeof id === "number" ? id : Number(id)
465
+
466
+ if (!Number.isFinite(pid)) return []
467
+ const cached = this.#ancestorsCache.get(pid)
468
+
469
+ if (cached) return cached
470
+
471
+ const lineage: Ancestor[] = ancestorLineage(this.#db, pid).map((r) => ({
472
+ id: r.id,
473
+ placetype: r.placetype,
474
+ name: r.name,
475
+ }))
476
+
477
+ this.#ancestorsCache.set(pid, lineage)
478
+
479
+ return lineage
480
+ }
481
+
482
+ /**
483
+ * Surface an unknown strategy name LOUDLY (once per name) rather than swallowing it silently — an invisible no-op is
484
+ * exactly the hidden-dependency failure mode we avoid (see the provenance-first design value). We warn rather than
485
+ * throw so a convention asset built against a newer code revision (one that adds a strategy) degrades gracefully on
486
+ * an older build instead of taking down resolution.
487
+ */
488
+ #warnUnknownStrategy(name: string): void {
489
+ if (this.#warnedUnknownStrategies.has(name)) return
490
+ this.#warnedUnknownStrategies.add(name)
491
+
492
+ console.warn(
493
+ `WOFSQLitePlaceLookup: a convention names strategy "${name}", which this build does not register ` +
494
+ `(known: ${[...this.#strategies.keys()].join(", ")}). Skipping it. If the convention asset was built ` +
495
+ `against a newer code revision, rebuild the asset for this one.`
496
+ )
497
+ }
498
+
499
+ /**
500
+ * Strategy `postcode_area_resolution` — the coordinate-first locality path, strictly conditioned (a sibling postcode
501
+ * AND a postcode_locality table AND a locality query). Returns `null` — so the dispatcher falls through to the next
502
+ * strategy — when the condition is unmet or the postcode isn't in the table; otherwise the soft-scored postcode∪name
503
+ * candidate set.
504
+ */
505
+ #postcodeAreaResolution(query: FindPlaceQuery, convention: ResolvedConvention): Promise<PlaceCandidate[] | null> {
506
+ if (!(query.postcode && this.#postcodeLocalityExtract && this.#isLocalityQuery(query))) {
507
+ return Promise.resolve(null)
508
+ }
509
+
510
+ return this.#findLocalityCoordFirst(query, this.#postcodeLocalityExtract, convention)
511
+ }
512
+
513
+ /**
514
+ * Strategy `fallback_fuzzy_name_match` — the BM25 FTS name-match over the gazetteer, the universal fallback. Always
515
+ * returns an array (never null), so it terminates the dispatch chain.
516
+ */
517
+ async #fuzzyNameMatch(query: FindPlaceQuery, forceExtract?: ResolvedExtract): Promise<PlaceCandidate[]> {
518
+ const limit = query.limit ?? 10
519
+
520
+ // Expand the placetype filter through the shared equivalence table (core/resolver): a
521
+ // `locality` query must also reach `borough` / `localadmin` rows — Brooklyn-the-borough
522
+ // (pop 2.5M) is a borough, not a locality, and a strict filter made it unreachable so the
523
+ // fuzzy "Brooklyn Park, MN" won instead. Order-preserving: the FIRST entry stays the
524
+ // requested placetype, which is what extract routing keys off below.
525
+ const placetypes = expandPlacetypeFilter(normalizePlacetypes(query.placetype)) as WOFPlacetype[] | null
526
+ // Postcode-typed queries keep the #920 fused name-law shape; everything else splits on
527
+ // intra-token punctuation so hyphenated names reach the FTS as their real terms (#945).
528
+ const ftsQuery = sanitizeFTSQuery(query.text, { fuseTokens: placetypes?.includes("postalcode") ?? false })
529
+
530
+ if (!ftsQuery) return []
531
+
532
+ // Pick the extract for this query. Multi-extract routing is placetype-driven; a query without
533
+ // `placetype` always goes to main. (Mixed-placetype queries with multiple extracts aren't
534
+ // supported in v1 — caller can issue two findPlace calls and merge in TS if needed.)
535
+ const firstPlacetype = placetypes?.[0]
536
+
537
+ // Bias fan-out (#58/proximity-bias): a country-less query WITH proximity hints must see the
538
+ // cross-extract ambiguity the hints exist to resolve — "48026" lives in postalcode-us AND
539
+ // postalcode-intl, and single-extract routing would hide one side. Query every matching extract
540
+ // (self-recursion with a extract pin), merge by id, and re-sort by the same (exact, prominence)
541
+ // keys the per-extract tier sort used. Bounded: hints + no country + >1 matching extract only.
542
+ const hasBiasHints = !!query.near || (query.bias?.length ?? 0) > 0
543
+
544
+ if (!forceExtract && hasBiasHints && !query.country) {
545
+ const matching = pickExtractsForPlacetype(this.#extracts, firstPlacetype)
546
+
547
+ if (matching.length > 1) {
548
+ const pools: PlaceCandidate[][] = []
549
+
550
+ for (const sh of matching) {
551
+ pools.push(await this.#fuzzyNameMatch(query, sh))
552
+ }
553
+
554
+ const byID = new Map<PlaceCandidate["id"], PlaceCandidate>()
555
+
556
+ for (const c of pools.flat()) {
557
+ if (!byID.has(c.id)) {
558
+ byID.set(c.id, c)
559
+ }
560
+ }
561
+
562
+ const merged = [...byID.values()]
563
+
564
+ merged.sort(
565
+ (a, b) =>
566
+ Number(b.exactMatch ?? false) - Number(a.exactMatch ?? false) ||
567
+ (b.prominence ?? 0) - (a.prominence ?? 0) ||
568
+ b.score - a.score
569
+ )
570
+
571
+ return merged.slice(0, limit)
572
+ }
573
+ }
574
+
575
+ const extract =
576
+ forceExtract ??
577
+ pickExtractForPlacetype(this.#extracts, firstPlacetype, {
578
+ country: query.country,
579
+ countriesBySchema: this.#extractCountries,
580
+ })
581
+
582
+ // bare schema name; safe to interpolate (validated at construction)
583
+ const sch = extract.schemaName
584
+
585
+ const rawRows = fetchSearchRows({
586
+ db: this.#db,
587
+ schemaName: sch,
588
+ query,
589
+ placetypes,
590
+ ftsQuery,
591
+ limit,
592
+ hasBboxIndex: this.#hasBboxIndex,
593
+ hasPopulationIndex: this.#hasPopulationIndex,
594
+ encyclopedicClauses: this.#encyclopedicClauses,
595
+ weights: this.#weights,
596
+ })
597
+
598
+ const scoring = {
599
+ query,
600
+ placetypes,
601
+ queryLen: query.text.length,
602
+ weights: this.#weights,
603
+ }
604
+
605
+ const candidates = rawRows.map((row) => candidateFromSearchRow(row, scoring))
606
+
607
+ rankCandidates(candidates, {
608
+ db: this.#db,
609
+ schemaName: sch,
610
+ query,
611
+ weights: this.#weights,
612
+ })
613
+
614
+ return candidates.slice(0, limit)
615
+ }
616
+
617
+ #isLocalityQuery(query: FindPlaceQuery): boolean {
618
+ const pts = normalizePlacetypes(query.placetype)
619
+
620
+ return !pts || pts.includes("locality")
621
+ }
622
+
623
+ /**
624
+ * Resolve the effective convention for a query (the Geographic Rule Engine entry point). The ancestor chain is keyed
625
+ * by WOF polygon id; for #289 it carries just the country level — resolved from `query.country` via the cached
626
+ * code→WOF-id lookup — so the EU locales, which have no override rows, resolve to `WORLD_DEFAULT` and dispatch is
627
+ * byte-identical to the pre-engine path. E4 (JP) extends the chain with the resolved locality's `ancestors` row, so a
628
+ * region/locality-level convention (e.g. Sapporo's grid) deep-merges over the country one.
629
+ */
630
+ #conventionFor(query: FindPlaceQuery): ResolvedConvention {
631
+ const chain: number[] = []
632
+
633
+ if (query.country) {
634
+ const cid = this.#countryWOFId(query.country)
635
+
636
+ if (cid !== null) {
637
+ chain.push(cid)
638
+ }
639
+ }
640
+
641
+ return resolveConvention(this.#conventionSource, chain)
642
+ }
643
+
644
+ /**
645
+ * Country ISO code → its WOF polygon id (the coarsest convention key). Cached — one indexed `spr` query per distinct
646
+ * country, then memoized (including a not-found `null`) so findPlace never pays for it twice.
647
+ */
648
+ #countryWOFId(code: string): number | null {
649
+ const cached = this.#countryWOFIdCache.get(code)
650
+
651
+ if (cached !== undefined) return cached
652
+ let id: number | null
653
+
654
+ try {
655
+ const row = this.#db
656
+ .prepare(`SELECT id FROM main.spr WHERE placetype = 'country' AND country = ? AND is_current != 0 LIMIT 1`)
657
+ .get(code) as { id: number } | undefined
658
+
659
+ id = row?.id ?? null
660
+ } catch {
661
+ id = null
662
+ }
663
+
664
+ this.#countryWOFIdCache.set(code, id)
665
+
666
+ return id
667
+ }
668
+
669
+ /**
670
+ * Coordinate-first locality resolution. The postcode_locality table maps the sibling postcode to the locality whose
671
+ * polygon contains the postcode centroid (+ a few nearby ones for the abutting- postcode case). We union those
672
+ * COORDINATE candidates with the FTS NAME candidates and soft-score the union `0.6·S_pc + 0.3·S_name + 0.1·S_pop` —
673
+ * so a small town the name-match never finds is recovered by the postcode, while an unambiguous name (Berlin) still
674
+ * wins on name + population. Returns null when the postcode isn't in the table (→ caller falls back to the FTS
675
+ * path).
676
+ */
677
+ async #findLocalityCoordFirst(
678
+ query: FindPlaceQuery,
679
+ sch: string,
680
+ convention: ResolvedConvention
681
+ ): Promise<PlaceCandidate[] | null> {
682
+ const w = convention.scoringWeights
683
+ const pc = query.postcode!.trim()
684
+ const pcWhere = query.country ? "postcode = ? AND country = ?" : "postcode = ?"
685
+ const pcParams: SQLInputValue[] = query.country ? [pc, query.country] : [pc]
686
+
687
+ const pcRows = allRows<{ id: number; aliases: string | null; dist: number; containing: number }>(
688
+ this.#db.prepare(
689
+ `SELECT locality_id AS id, aliases, distance_km AS dist, is_containing AS containing
690
+ FROM ${sch}.${POSTCODE_LOCALITY_TABLE} WHERE ${pcWhere}`
691
+ ),
692
+ ...pcParams
693
+ )
694
+
695
+ if (!pcRows.length) return null
696
+
697
+ const limit = query.limit ?? 10
698
+ // Name-match candidates via the normal FTS path (postcode cleared → no recursion).
699
+ const ftsCands = await this.findPlace({ ...query, postcode: undefined, limit: Math.max(limit, 10) })
700
+
701
+ const pcInfo = new Map<number, { dist: number; containing: boolean; aliases: string[] }>()
702
+
703
+ for (const r of pcRows) {
704
+ pcInfo.set(r.id, { dist: r.dist, containing: r.containing === 1, aliases: r.aliases ? r.aliases.split("|") : [] })
705
+ }
706
+
707
+ // #475 (opt-in): observed postal-city aliases for this postcode, keyed by the geographic
708
+ // locality name they map to. A user-typed postal city ("Antioch", 37013) becomes a name-match
709
+ // alias for the geographic locality the postcode sits in ("Nashville"). Empty when the reader
710
+ // isn't supplied → the scoring loop below is byte-identical to pre-#475.
711
+ const postalAliasByGeo = new Map<string, string[]>()
712
+
713
+ if (this.#postalCityAliases) {
714
+ for (const a of await this.#postalCityAliases.getDivergentAliases(pc)) {
715
+ const key = cfNormalize(a.geoLocality)
716
+
717
+ if (!key) continue
718
+ const bag = postalAliasByGeo.get(key)
719
+
720
+ if (bag) {
721
+ bag.push(a.postalCity)
722
+ } else {
723
+ postalAliasByGeo.set(key, [a.postalCity])
724
+ }
725
+ }
726
+ }
727
+
728
+ const merged = new Map<number, PlaceCandidate>()
729
+
730
+ for (const c of ftsCands) {
731
+ merged.set(c.id as number, c)
732
+ }
733
+
734
+ const missing = [...pcInfo.keys()].filter((id) => !merged.has(id))
735
+
736
+ for (const row of this.#fetchLocalitiesByID(missing)) {
737
+ merged.set(row.id, row)
738
+ }
739
+
740
+ const scored: Array<PlaceCandidate & { exact: boolean }> = []
741
+
742
+ for (const cand of merged.values()) {
743
+ const info = pcInfo.get(cand.id as number)
744
+ const sPc = info ? (info.containing ? 1 : Math.exp(-info.dist / CF_PC_DECAY_KM)) : 0
745
+ // Fold any postal-city aliases for this candidate's geographic name into the soft name match
746
+ // (#475). `postalAliasByGeo` is empty unless the opt-in reader was supplied, so when off this
747
+ // reduces to the original `info?.aliases ?? []` and the score is unchanged.
748
+ const wofAliases = info?.aliases ?? []
749
+
750
+ const aliases = postalAliasByGeo.size
751
+ ? [...wofAliases, ...(postalAliasByGeo.get(cfNormalize(cand.name)) ?? [])]
752
+ : wofAliases
753
+
754
+ const sName = softNameScore(query.text, cand.name, aliases)
755
+ const sPop = populationScaleTerm(cand.population, this.#weights)
756
+ scored.push({ ...cand, score: w.pc * sPc + w.name * sName + w.pop * sPop, exact: sName >= 1 })
757
+ }
758
+
759
+ // Exact-name tiering (same philosophy as the FTS path): an EXACT name/alias match tiers above
760
+ // coordinate-only candidates, with the soft-score breaking ties WITHIN a tier. This keeps an
761
+ // unambiguous city ("Berlin", exact + huge population) ahead of the fine-grained Ortsteil its
762
+ // postcode centroid lands in, while a small town the name-match never finds (no exact tier) is
763
+ // still recovered by its postcode's containing locality.
764
+ scored.sort((a, b) => Number(b.exact) - Number(a.exact) || b.score - a.score)
765
+
766
+ // Conflict flag: if the chosen locality is NOT the postcode's containing locality and sits far
767
+ // from it, the postcode and the city name disagree (a transposed / wrong-for-the-city postcode).
768
+ // We keep the name-chosen locality but flag it — the falsehood signal a BM25 geocoder can't give.
769
+ const top = scored[0]
770
+
771
+ if (top) {
772
+ // The postcode's geographic anchor: among the postcode's candidate localities that actually
773
+ // resolved (some — e.g. unnamed Ortsteile — are in the postcode table but not the admin DB),
774
+ // prefer the containing one, else the nearest. Postcodes whose centroid falls just outside
775
+ // every locality polygon still anchor to the closest town.
776
+ const anchorRow = pcRows
777
+ .filter((r) => merged.has(r.id))
778
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a freshly-built array; toSorted would double-allocate on a hot path
779
+ .sort((a, b) => b.containing - a.containing || a.dist - b.dist)[0]
780
+
781
+ const anchor = anchorRow ? merged.get(anchorRow.id) : undefined
782
+
783
+ if (
784
+ anchor &&
785
+ (top.id as number) !== anchorRow!.id &&
786
+ haversineKm(top.lat, top.lon, anchor.lat, anchor.lon) > CF_MISMATCH_KM
787
+ ) {
788
+ top.mismatch = true
789
+ }
790
+ }
791
+
792
+ return scored.slice(0, limit).map(({ exact, ...c }) => {
793
+ void exact
794
+
795
+ return c
796
+ })
797
+ }
798
+
799
+ /**
800
+ * Fetch locality spr rows (from main) for the postcode-injected candidate ids the FTS set missed.
801
+ */
802
+ #fetchLocalitiesByID(ids: number[]): PlaceCandidate[] {
803
+ if (!ids.length) return []
804
+ const hasPop = this.#hasPopulationIndex.get("main") === true
805
+ const popSelect = hasPop ? `pp.population AS population` : `NULL AS population`
806
+ const popJoin = hasPop ? `LEFT JOIN main.${PLACE_POPULATION_TABLE} pp ON pp.id = s.id` : ""
807
+ const ph = ids.map(() => "?").join(", ")
808
+
809
+ const rows = allRows<RawSearchRow>(
810
+ this.#db.prepare(
811
+ `SELECT s.id AS id, s.name AS name, s.country AS country, s.parent_id AS parent_id,
812
+ s.latitude AS lat, s.longitude AS lon, s.placetype AS placetype, ${popSelect}
813
+ FROM main.spr s ${popJoin}
814
+ WHERE s.id IN (${ph}) AND s.is_current != 0`
815
+ ),
816
+ ...ids
817
+ )
818
+
819
+ return rows.map((row) => {
820
+ const c: PlaceCandidate = {
821
+ id: row.id,
822
+ name: row.name,
823
+ placetype: row.placetype as WOFPlacetype,
824
+ country: row.country ?? "",
825
+ lat: row.lat ?? 0,
826
+ lon: row.lon ?? 0,
827
+ parent_id: row.parent_id ?? undefined,
828
+ score: 0,
829
+ }
830
+
831
+ if (row.population !== null && row.population > 0) {
832
+ c.population = row.population
833
+ }
834
+
835
+ return c
836
+ })
837
+ }
838
+
839
+ [Symbol.dispose](): void {
840
+ // Only when we opened it. A caller who passed a pre-opened client keeps using it after this returns — the FTS
841
+ // build this lookup performed lives on their connection, and closing it would take that with us.
842
+ this.#resources[Symbol.dispose]()
843
+ }
844
+
845
+ /**
846
+ * Build the FTS5 virtual table from the `names` + `places` tables.
847
+ */
848
+ #ensureFTS(): void {
849
+ buildPlaceSearchFTS(this.#db)
850
+ }
851
+
852
+ #assertFTSExists(): void {
853
+ if (!placeSearchFTSExists(this.#db)) {
854
+ throw new Error(
855
+ "WOFSQLitePlaceLookup: `place_search` FTS5 table is missing. Pass `buildFTS: true` to build it on open, or run `mailwoman gazetteer build fts <path-to-wof.db>` ahead of time (see resolver-wof-sqlite/README.md)."
856
+ )
857
+ }
858
+ }
859
+ }
860
+
861
+ export type { RankingWeights } from "#ranking-weights"