@mailwoman/resolver-wof-sqlite 7.2.0 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/address-point-interpolation.ts +207 -0
- package/address-point-schema.ts +107 -0
- package/address-point.ts +122 -0
- package/ancestry-backfill.ts +205 -0
- package/ancestry.ts +70 -0
- package/build-candidate.ts +351 -0
- package/build-slim.ts +394 -0
- package/candidate-fts.ts +43 -0
- package/candidate-lookup.ts +382 -0
- package/candidate-schema.ts +166 -0
- package/coincident-roles.ts +240 -0
- package/convention.ts +152 -0
- package/fst-autocomplete.ts +187 -0
- package/fst-builder.ts +291 -0
- package/fst-deserialize-web.ts +164 -0
- package/fst-matcher.ts +150 -0
- package/fst-serialize.ts +311 -0
- package/fst-types.ts +78 -0
- package/fts.ts +318 -0
- package/geo.ts +140 -0
- package/geonames-aliases.ts +317 -0
- package/geonames-postal.ts +150 -0
- package/index.ts +117 -0
- package/interpolation.ts +232 -0
- package/lookup.ts +1498 -0
- package/package.json +168 -82
- package/poi-lookup.ts +319 -0
- package/poi-schema.ts +147 -0
- package/postal-city-alias-lookup.ts +89 -0
- package/postal-city-alias-schema.ts +75 -0
- package/postal-city-candidate-schema.ts +81 -0
- package/postcode-point-lookup.ts +64 -0
- package/reverse.ts +429 -0
- package/schema.ts +176 -0
- package/sharding.ts +235 -0
- package/sqlite-convention-source.ts +61 -0
- package/sqlite-utils.ts +25 -0
- package/street-centroid-schema.ts +124 -0
- package/street-centroid.ts +124 -0
- package/street-morphology-fst-builder.ts +230 -0
- package/street-name-lookup.ts +101 -0
- package/street-normalize.ts +302 -0
- package/street-segment-schema.ts +104 -0
- package/types.ts +164 -0
- package/unified-schema.ts +171 -0
package/poi-schema.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed schema for poi.db — spatial layer #1 (spec §3.4). One clustered `WITHOUT ROWID` B-tree
|
|
7
|
+
* keyed `(h3_cell, category_id, neg_rank, rowid_key)` so "everything near this res-9 cell" is a
|
|
8
|
+
* contiguous key range (the byte-range/httpvfs access pattern, same discipline as the candidate
|
|
9
|
+
* gazetteer). Rows carry denormalized name/brand/coords; category ids are small ints via the
|
|
10
|
+
* `poi_category_codes` dictionary (poi-taxonomy category ids are the string side). The DB also
|
|
11
|
+
* embeds the layer-contract tables from `@mailwoman/core/layers` — the builder writes the
|
|
12
|
+
* manifest (tier `shipped`, spine `h3` res 9) and per-res-6-cell coverage.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DatabaseSync } from "node:sqlite"
|
|
16
|
+
|
|
17
|
+
import type { LayerContractDatabase } from "@mailwoman/core/layers"
|
|
18
|
+
import { sql, type Kysely } from "kysely"
|
|
19
|
+
|
|
20
|
+
/** One POI row. Clustered PK: h3_cell → category_id → neg_rank → rowid_key. */
|
|
21
|
+
export interface POITable {
|
|
22
|
+
/** 48-bit short H3 cell at res 9 (`latLngToCell` → `shortenH3Cell`). */
|
|
23
|
+
h3_cell: number
|
|
24
|
+
/** Small int from {@link POICategoryCodeTable}; 0 = uncategorized. */
|
|
25
|
+
category_id: number
|
|
26
|
+
/** `-log10(confidence + epsilon)` so ASC = most-confident-first within a cell+category. */
|
|
27
|
+
neg_rank: number
|
|
28
|
+
/** Uniquifier within the clustered key (builder-assigned monotonic int). */
|
|
29
|
+
rowid_key: number
|
|
30
|
+
name: string | null
|
|
31
|
+
/** Lowercased, diacritic-flattened probe key for exact name lookups. */
|
|
32
|
+
name_key: string | null
|
|
33
|
+
brand_wikidata: string | null
|
|
34
|
+
latitude: number
|
|
35
|
+
longitude: number
|
|
36
|
+
/** ISO 3166-1 alpha-2 (from the Overture partition). */
|
|
37
|
+
country: string
|
|
38
|
+
/** Overture existence confidence (already filtered ≥ 0.85 at build). */
|
|
39
|
+
confidence: number
|
|
40
|
+
/** GERS id — nullable METADATA ONLY, never a key (the #470 rule). */
|
|
41
|
+
gers_id: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Staging mirror — every column nullable except the coords (the loader fills positionally; the materialize SELECT
|
|
46
|
+
* enforces completeness).
|
|
47
|
+
*/
|
|
48
|
+
export interface POIStageTable {
|
|
49
|
+
h3_cell: number | null
|
|
50
|
+
category_id: number | null
|
|
51
|
+
neg_rank: number | null
|
|
52
|
+
rowid_key: number | null
|
|
53
|
+
name: string | null
|
|
54
|
+
name_key: string | null
|
|
55
|
+
brand_wikidata: string | null
|
|
56
|
+
latitude: number
|
|
57
|
+
longitude: number
|
|
58
|
+
country: string | null
|
|
59
|
+
confidence: number | null
|
|
60
|
+
gers_id: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `(id → poi-taxonomy category id)` dictionary, e.g. `3 → "cafe"`. */
|
|
64
|
+
export interface POICategoryCodeTable {
|
|
65
|
+
id: number
|
|
66
|
+
category: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface POIDatabase extends LayerContractDatabase {
|
|
70
|
+
poi: POITable
|
|
71
|
+
poi_stage: POIStageTable
|
|
72
|
+
poi_category_codes: POICategoryCodeTable
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Clustered-key-order column list shared by builder + `INSERT INTO poi SELECT … FROM poi_stage`. */
|
|
76
|
+
export const POI_COLUMNS = [
|
|
77
|
+
"h3_cell",
|
|
78
|
+
"category_id",
|
|
79
|
+
"neg_rank",
|
|
80
|
+
"rowid_key",
|
|
81
|
+
"name",
|
|
82
|
+
"name_key",
|
|
83
|
+
"brand_wikidata",
|
|
84
|
+
"latitude",
|
|
85
|
+
"longitude",
|
|
86
|
+
"country",
|
|
87
|
+
"confidence",
|
|
88
|
+
"gers_id",
|
|
89
|
+
] as const
|
|
90
|
+
|
|
91
|
+
export async function createPOIStagingTables(db: Kysely<POIDatabase>): Promise<void> {
|
|
92
|
+
await db.schema
|
|
93
|
+
.createTable("poi_category_codes")
|
|
94
|
+
.addColumn("id", "integer", (c) => c.primaryKey())
|
|
95
|
+
.addColumn("category", "text", (c) => c.unique())
|
|
96
|
+
.execute()
|
|
97
|
+
await db.schema
|
|
98
|
+
.createTable("poi_stage")
|
|
99
|
+
.addColumn("h3_cell", "integer")
|
|
100
|
+
.addColumn("category_id", "integer")
|
|
101
|
+
.addColumn("neg_rank", "real")
|
|
102
|
+
.addColumn("rowid_key", "integer")
|
|
103
|
+
.addColumn("name", "text")
|
|
104
|
+
.addColumn("name_key", "text")
|
|
105
|
+
.addColumn("brand_wikidata", "text")
|
|
106
|
+
.addColumn("latitude", "real")
|
|
107
|
+
.addColumn("longitude", "real")
|
|
108
|
+
.addColumn("country", "text")
|
|
109
|
+
.addColumn("confidence", "real")
|
|
110
|
+
.addColumn("gers_id", "text")
|
|
111
|
+
.execute()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function createPOITable(db: Kysely<POIDatabase>): Promise<void> {
|
|
115
|
+
await db.schema
|
|
116
|
+
.createTable("poi")
|
|
117
|
+
.addColumn("h3_cell", "integer", (c) => c.notNull())
|
|
118
|
+
.addColumn("category_id", "integer", (c) => c.notNull())
|
|
119
|
+
.addColumn("neg_rank", "real", (c) => c.notNull())
|
|
120
|
+
.addColumn("rowid_key", "integer", (c) => c.notNull())
|
|
121
|
+
.addColumn("name", "text")
|
|
122
|
+
.addColumn("name_key", "text")
|
|
123
|
+
.addColumn("brand_wikidata", "text")
|
|
124
|
+
.addColumn("latitude", "real", (c) => c.notNull())
|
|
125
|
+
.addColumn("longitude", "real", (c) => c.notNull())
|
|
126
|
+
.addColumn("country", "text", (c) => c.notNull())
|
|
127
|
+
.addColumn("confidence", "real", (c) => c.notNull())
|
|
128
|
+
.addColumn("gers_id", "text")
|
|
129
|
+
.addPrimaryKeyConstraint("poi_pk", ["h3_cell", "category_id", "neg_rank", "rowid_key"])
|
|
130
|
+
// `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
|
|
131
|
+
.modifyEnd(sql`without rowid`)
|
|
132
|
+
.execute()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Secondary index for the FTS-hydration path. Builders call this AFTER the bulk materialize (index-after-load). */
|
|
136
|
+
export async function createPOINameKeyIndex(db: Kysely<POIDatabase>): Promise<void> {
|
|
137
|
+
await db.schema.createIndex("poi_name_key").on("poi").column("name_key").execute()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const POI_FTS_TABLE = "poi_search"
|
|
141
|
+
|
|
142
|
+
/** FTS5 stays raw SQL by project rule (Kysely can't express virtual tables). Content-keyed by name_key. */
|
|
143
|
+
export function createPOISearchFTS(db: DatabaseSync): void {
|
|
144
|
+
db.exec(
|
|
145
|
+
`CREATE VIRTUAL TABLE ${POI_FTS_TABLE} USING fts5(name, name_key UNINDEXED, h3_cell UNINDEXED, tokenize = 'unicode61')`
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Node reader over the POSTAL-CITY ALIAS table (`postal-city-alias-<cc>.db`) — the observed
|
|
7
|
+
* `postal_city → geo_locality` aliases per postcode (`build-postal-city-alias.ts`). Consumed by
|
|
8
|
+
* {@link WOFSqlitePlaceLookup}'s coordinate-first locality scorer: a user-typed postal city
|
|
9
|
+
* ("Antioch", postcode 37013) becomes a name-match alias for the geographic locality the postcode
|
|
10
|
+
* actually sits in ("Nashville"), so the right place tiers to the top instead of a same-named
|
|
11
|
+
* town in another state. Opt-in — the lookup is only constructed when a path is supplied, and
|
|
12
|
+
* absent it the resolver is byte-identical.
|
|
13
|
+
*
|
|
14
|
+
* The reader returns RAW divergent rows for a postcode; normalization + name-matching against the
|
|
15
|
+
* candidate localities is the scorer's job (it owns the case/diacritic fold the soft name score
|
|
16
|
+
* uses), keeping one normalizer in one place.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DatabaseSync } from "node:sqlite"
|
|
20
|
+
|
|
21
|
+
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
22
|
+
|
|
23
|
+
import type { PostalCityAliasDatabase } from "./postal-city-alias-schema.ts"
|
|
24
|
+
|
|
25
|
+
export interface WOFPostalCityAliasLookupOpts {
|
|
26
|
+
/** Path to a `postal-city-alias-<cc>.db` built by `build-postal-city-alias.ts`. Opened read-only. */
|
|
27
|
+
databasePath?: string
|
|
28
|
+
/** Pre-opened handle (tests / shared connections). Mutually exclusive with `databasePath`. */
|
|
29
|
+
database?: DatabaseSync
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One divergent alias edge: the postal-system name and the geographic locality it maps to. */
|
|
33
|
+
export interface PostalCityAlias {
|
|
34
|
+
/** The postal-system surface (what a user types). */
|
|
35
|
+
postalCity: string
|
|
36
|
+
/** The geographic locality name the postcode sits in (≈ the gazetteer's canonical name). */
|
|
37
|
+
geoLocality: string
|
|
38
|
+
/** Observed usage count — the evidence weight. */
|
|
39
|
+
n: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reader over `postal_city_alias`. The only query is a postcode-scoped probe for DIVERGENT rows (where the postal name
|
|
44
|
+
* differs from the geographic name — the rows that carry alias signal), issued via the typed Kysely query builder
|
|
45
|
+
* against {@link PostalCityAliasDatabase}.
|
|
46
|
+
*/
|
|
47
|
+
export class WOFPostalCityAliasLookup {
|
|
48
|
+
#db: DatabaseSync
|
|
49
|
+
#kdb: DatabaseClient<PostalCityAliasDatabase>
|
|
50
|
+
#ownsDB: boolean
|
|
51
|
+
|
|
52
|
+
constructor(opts: WOFPostalCityAliasLookupOpts) {
|
|
53
|
+
if (opts.database) {
|
|
54
|
+
this.#db = opts.database
|
|
55
|
+
this.#ownsDB = false
|
|
56
|
+
} else if (opts.databasePath) {
|
|
57
|
+
this.#db = new DatabaseSync(opts.databasePath, { readOnly: true })
|
|
58
|
+
this.#ownsDB = true
|
|
59
|
+
} else {
|
|
60
|
+
throw new Error("WOFPostalCityAliasLookup needs `databasePath` or `database`")
|
|
61
|
+
}
|
|
62
|
+
// `#kdb` wraps `#db` for the typed query; close() owns the raw handle directly (sync).
|
|
63
|
+
this.#kdb = new DatabaseClient<PostalCityAliasDatabase>({ database: this.#db })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Divergent postal-city aliases for a postcode (empty when the postcode isn't in the table). The scorer groups these
|
|
68
|
+
* by normalized `geoLocality` and appends the `postalCity` surfaces to the matching candidate locality's alias set.
|
|
69
|
+
*/
|
|
70
|
+
async getDivergentAliases(postcode: string): Promise<PostalCityAlias[]> {
|
|
71
|
+
const pc = postcode.trim()
|
|
72
|
+
|
|
73
|
+
if (!pc) return []
|
|
74
|
+
const rows = await this.#kdb
|
|
75
|
+
.selectFrom("postal_city_alias")
|
|
76
|
+
.select(["postal_city", "geo_locality", "n"])
|
|
77
|
+
.where("postcode", "=", pc)
|
|
78
|
+
.where("divergent", "=", 1)
|
|
79
|
+
.execute()
|
|
80
|
+
|
|
81
|
+
return rows.map((r) => ({ postalCity: String(r.postal_city), geoLocality: String(r.geo_locality), n: Number(r.n) }))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close(): void {
|
|
85
|
+
if (this.#ownsDB) {
|
|
86
|
+
this.#db.close()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed schema for the POSTAL-CITY ALIAS table (`postal-city-alias-<cc>.db`, built by
|
|
7
|
+
* `scripts/build-postal-city-alias.ts`) — the single source of truth for the columns shared by
|
|
8
|
+
* the BUILDER and the READER ({@link WOFPostalCityAliasLookup}). Like {@link CandidateTable}, the
|
|
9
|
+
* contract is a Kysely `Database` interface plus the table DDL as a string, so a column rename in
|
|
10
|
+
* the builder is a compile error in the reader.
|
|
11
|
+
*
|
|
12
|
+
* Provenance discipline (provenance-first): this is a SIBLING table to the PIP-derived
|
|
13
|
+
* `postcode_locality` data, never mixed into it — one table, one provenance class. Each row is an
|
|
14
|
+
* OBSERVED `(postcode, postal_city, geo_locality)` aggregate from Overture's `postal_city` field
|
|
15
|
+
* with a usage count `n`; `divergent = 1` exactly when `postal_city != geo_locality` (the alias
|
|
16
|
+
* signal — the only rows the resolver consumes).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Kysely } from "kysely"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One observed postal-city aggregate. The natural key is `(postcode, postal_city, geo_locality)`; the builder enforces
|
|
23
|
+
* a `MIN_COUNT` floor on `n`, so every row is a non-trivial usage.
|
|
24
|
+
*/
|
|
25
|
+
export interface PostalCityAliasTable {
|
|
26
|
+
/** The postcode the aggregate is scoped to (the resolver probes by this). */
|
|
27
|
+
postcode: string
|
|
28
|
+
/** What the postal system calls the place (the surface a user is likely to type). */
|
|
29
|
+
postal_city: string
|
|
30
|
+
/** The geographic locality name the postcode actually sits in (≈ the gazetteer's canonical name). */
|
|
31
|
+
geo_locality: string
|
|
32
|
+
/** Observed row count — the evidence weight behind this alias. */
|
|
33
|
+
n: number
|
|
34
|
+
/** 1 when `postal_city != geo_locality` (the alias signal); 0 when they agree. */
|
|
35
|
+
divergent: number
|
|
36
|
+
/** Provenance: the dataset this aggregate came from (e.g. `overture:US`). */
|
|
37
|
+
source: string
|
|
38
|
+
/** The pinned data release the aggregate was computed from. */
|
|
39
|
+
release: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The postal-city-alias database schema for `new DatabaseClient<PostalCityAliasDatabase>(...)`. */
|
|
43
|
+
export interface PostalCityAliasDatabase {
|
|
44
|
+
postal_city_alias: PostalCityAliasTable
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The `postal_city_alias` column order — the builder's INSERT derives its column list from this. */
|
|
48
|
+
export const POSTAL_CITY_ALIAS_COLUMNS = [
|
|
49
|
+
"postcode",
|
|
50
|
+
"postal_city",
|
|
51
|
+
"geo_locality",
|
|
52
|
+
"n",
|
|
53
|
+
"divergent",
|
|
54
|
+
"source",
|
|
55
|
+
"release",
|
|
56
|
+
] as const
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create the `postal_city_alias` table + its two probe indexes. Kept here (not only in the builder) so tests can stand
|
|
60
|
+
* up a fixture DB with the exact production shape. Pass a {@link DatabaseClient} (or any `Kysely`) over the alias DB.
|
|
61
|
+
*/
|
|
62
|
+
export async function createPostalCityAliasTable(db: Kysely<PostalCityAliasDatabase>): Promise<void> {
|
|
63
|
+
await db.schema
|
|
64
|
+
.createTable("postal_city_alias")
|
|
65
|
+
.addColumn("postcode", "text", (c) => c.notNull())
|
|
66
|
+
.addColumn("postal_city", "text", (c) => c.notNull())
|
|
67
|
+
.addColumn("geo_locality", "text", (c) => c.notNull())
|
|
68
|
+
.addColumn("n", "integer", (c) => c.notNull())
|
|
69
|
+
.addColumn("divergent", "integer", (c) => c.notNull())
|
|
70
|
+
.addColumn("source", "text", (c) => c.notNull())
|
|
71
|
+
.addColumn("release", "text", (c) => c.notNull())
|
|
72
|
+
.execute()
|
|
73
|
+
await db.schema.createIndex("idx_pca_postcode").on("postal_city_alias").column("postcode").execute()
|
|
74
|
+
await db.schema.createIndex("idx_pca_pair").on("postal_city_alias").columns(["postal_city", "geo_locality"]).execute()
|
|
75
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Typed schema for the POSTAL-CITY CANDIDATE side-index (#741 / #475) — a small `(name_key,
|
|
7
|
+
* postcode) → geo-locality` table that lives alongside the byte-range `candidate` table so the
|
|
8
|
+
* candidate-backend resolver (the demo/CLI default) can do what the FTS coordinate-first scorer
|
|
9
|
+
* does: resolve a user-typed POSTAL city ("Antioch", 37013) to the geographic locality the
|
|
10
|
+
* postcode sits in ("Nashville").
|
|
11
|
+
*
|
|
12
|
+
* Why a SIDE-INDEX, not cloned `candidate` rows: the `candidate` B-tree is keyed `(name_key,
|
|
13
|
+
* country_id, region_id, placetype_id, …)` and ranked population-first — it has no postcode
|
|
14
|
+
* dimension. A cloned alias row was tested (#741) and falsified: a sentinel rank is
|
|
15
|
+
* bare-name-safe but then loses to any in-region homonym, and there is no single rank that is
|
|
16
|
+
* both. The fix is an EXACT `(name_key, postcode)` probe that bypasses population/region ranking
|
|
17
|
+
* entirely — consulted only when the query carries a postcode, so the common no-postcode path is
|
|
18
|
+
* untouched.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { sql, type Kysely } from "kysely"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One postal-city → geo-locality edge, keyed exactly by `(name_key, postcode)`. The probe returns the geographic
|
|
25
|
+
* locality directly; the denormalized name/coord avoid a join back to `candidate`.
|
|
26
|
+
*/
|
|
27
|
+
export interface PostalCityCandidateTable {
|
|
28
|
+
/** {@link normalizeLocalityForKey} of the postal-city name — the build/query-consistent probe key. */
|
|
29
|
+
name_key: string
|
|
30
|
+
/** The postcode the alias is scoped to (the second half of the exact key). */
|
|
31
|
+
postcode: string
|
|
32
|
+
/** WOF id of the geographic locality the postcode sits in (the resolve target). */
|
|
33
|
+
spr_id: number
|
|
34
|
+
/** The geographic locality's display name. */
|
|
35
|
+
name: string
|
|
36
|
+
latitude: number
|
|
37
|
+
longitude: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The postal-city-candidate database schema for `new DatabaseClient<PostalCityCandidateDatabase>(...)`.
|
|
42
|
+
*/
|
|
43
|
+
export interface PostalCityCandidateDatabase {
|
|
44
|
+
postal_city_candidate: PostalCityCandidateTable
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The table name the lookup probes (existence-gated, so an old candidate.db without it is byte-stable).
|
|
49
|
+
*/
|
|
50
|
+
export const POSTAL_CITY_CANDIDATE_TABLE = "postal_city_candidate"
|
|
51
|
+
|
|
52
|
+
/** Column order for the builder's positional INSERT. */
|
|
53
|
+
export const POSTAL_CITY_CANDIDATE_COLUMNS = [
|
|
54
|
+
"name_key",
|
|
55
|
+
"postcode",
|
|
56
|
+
"spr_id",
|
|
57
|
+
"name",
|
|
58
|
+
"latitude",
|
|
59
|
+
"longitude",
|
|
60
|
+
] as const
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create the side-index — a clustered `WITHOUT ROWID` B-tree on `(name_key, postcode)` so the resolve is a single exact
|
|
64
|
+
* probe. Idempotent (`IF NOT EXISTS`); pass a {@link DatabaseClient} (or any `Kysely`) over the candidate DB. The
|
|
65
|
+
* Kysely schema-builder is the house idiom for table creation — see `AGENTS.md` (inline-SQL → Kysely).
|
|
66
|
+
*/
|
|
67
|
+
export async function createPostalCityCandidateTable(db: Kysely<PostalCityCandidateDatabase>): Promise<void> {
|
|
68
|
+
await db.schema
|
|
69
|
+
.createTable(POSTAL_CITY_CANDIDATE_TABLE)
|
|
70
|
+
.ifNotExists()
|
|
71
|
+
.addColumn("name_key", "text", (c) => c.notNull())
|
|
72
|
+
.addColumn("postcode", "text", (c) => c.notNull())
|
|
73
|
+
.addColumn("spr_id", "integer", (c) => c.notNull())
|
|
74
|
+
.addColumn("name", "text", (c) => c.notNull())
|
|
75
|
+
.addColumn("latitude", "real", (c) => c.notNull())
|
|
76
|
+
.addColumn("longitude", "real", (c) => c.notNull())
|
|
77
|
+
.addPrimaryKeyConstraint("postal_city_candidate_pk", ["name_key", "postcode"])
|
|
78
|
+
// `WITHOUT ROWID` has no first-class builder; the raw modifier is the idiomatic fallback.
|
|
79
|
+
.modifyEnd(sql`without rowid`)
|
|
80
|
+
.execute()
|
|
81
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* SQLite-backed postcode lookup for the postcode anchor (#240). A thin exact-match resolver over
|
|
7
|
+
* one or more `postalcode-*.db` shards (the `spr` schema built by `build-unified-wof --placetypes
|
|
8
|
+
* postalcode`, then centroid-backfilled by `scripts/backfill-postcode-centroids.ts`).
|
|
9
|
+
*
|
|
10
|
+
* This is the production implementation of the `PostcodeResolver` interface consumed by
|
|
11
|
+
* `@mailwoman/neural`'s `extractPostcodeAnchors`. It is deliberately dumb: an indexed exact-match
|
|
12
|
+
* on the postcode string across every shard, unioned. No FTS, no ranking, no proximity — the
|
|
13
|
+
* anchor only needs "does this string exist as a postcode, in which countries, near where". A
|
|
14
|
+
* future WASM build swaps this for an FST-backed resolver behind the same `lookup()` seam.
|
|
15
|
+
*
|
|
16
|
+
* Why multiple shards instead of the multi-shard `WOFSqlitePlaceLookup`: that resolver routes a
|
|
17
|
+
* query to ONE shard by placetype, but every postcode shard shares `placetype='postalcode'`, so a
|
|
18
|
+
* single query could only ever hit one country's shard. The anchor needs the union across
|
|
19
|
+
* countries to build its country posterior, so it queries each shard directly.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { DatabaseSync } from "node:sqlite"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A gazetteer hit. `lat`/`lon` of 0 means the postcode is known but has no centroid (no admin parent).
|
|
26
|
+
*/
|
|
27
|
+
export interface PostcodePlace {
|
|
28
|
+
country: string
|
|
29
|
+
lat: number
|
|
30
|
+
lon: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LOOKUP_SQL =
|
|
34
|
+
"SELECT country, latitude AS lat, longitude AS lon FROM spr WHERE name = ? AND placetype = 'postalcode' AND is_current != 0"
|
|
35
|
+
|
|
36
|
+
export class WOFPostcodeLookup {
|
|
37
|
+
readonly #dbs: DatabaseSync[]
|
|
38
|
+
readonly #stmts: ReturnType<DatabaseSync["prepare"]>[]
|
|
39
|
+
|
|
40
|
+
/** Open each shard read-only and prepare its exact-match statement. */
|
|
41
|
+
constructor(dbPaths: readonly string[]) {
|
|
42
|
+
this.#dbs = dbPaths.map((p) => new DatabaseSync(p, { readOnly: true }))
|
|
43
|
+
this.#stmts = this.#dbs.map((db) => db.prepare(LOOKUP_SQL))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Exact-match the postcode across every shard and union the rows. */
|
|
47
|
+
lookup(postcode: string): PostcodePlace[] {
|
|
48
|
+
const out: PostcodePlace[] = []
|
|
49
|
+
|
|
50
|
+
for (const stmt of this.#stmts) {
|
|
51
|
+
for (const row of stmt.all(postcode)) {
|
|
52
|
+
out.push({ country: String(row.country), lat: Number(row.lat), lon: Number(row.lon) })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
close(): void {
|
|
60
|
+
for (const db of this.#dbs) {
|
|
61
|
+
db.close()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|