@mailwoman/resolver-wof-sqlite 8.6.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ancestry-backfill.ts +103 -64
- package/build-candidate.ts +133 -25
- package/candidate-lookup.ts +15 -2
- package/coverage-manifest-schema.ts +1 -1
- package/fst-builder.ts +51 -15
- package/fst-deserialize-web.ts +3 -1
- package/fst-freshness.ts +333 -0
- package/fst-serialize.ts +10 -1
- package/fst-types.ts +23 -0
- package/geo.ts +16 -41
- package/geonames-aliases.ts +105 -37
- package/geonames-postal.ts +65 -21
- package/index.ts +8 -1
- package/interpolation.ts +2 -1
- package/out/ancestry-backfill.d.ts +37 -18
- package/out/ancestry-backfill.d.ts.map +1 -1
- package/out/ancestry-backfill.js +82 -52
- package/out/ancestry-backfill.js.map +1 -1
- package/out/build-candidate.d.ts +9 -0
- package/out/build-candidate.d.ts.map +1 -1
- package/out/build-candidate.js +104 -9
- package/out/build-candidate.js.map +1 -1
- package/out/candidate-lookup.d.ts.map +1 -1
- package/out/candidate-lookup.js +12 -2
- package/out/candidate-lookup.js.map +1 -1
- package/out/coverage-manifest-schema.d.ts +1 -1
- package/out/coverage-manifest-schema.js +1 -1
- package/out/fst-builder.d.ts.map +1 -1
- package/out/fst-builder.js +43 -12
- package/out/fst-builder.js.map +1 -1
- package/out/fst-deserialize-web.d.ts.map +1 -1
- package/out/fst-deserialize-web.js +2 -1
- package/out/fst-deserialize-web.js.map +1 -1
- package/out/fst-freshness.d.ts +138 -0
- package/out/fst-freshness.d.ts.map +1 -0
- package/out/fst-freshness.js +238 -0
- package/out/fst-freshness.js.map +1 -0
- package/out/fst-serialize.d.ts +6 -0
- package/out/fst-serialize.d.ts.map +1 -1
- package/out/fst-serialize.js +8 -1
- package/out/fst-serialize.js.map +1 -1
- package/out/fst-types.d.ts +26 -0
- package/out/fst-types.d.ts.map +1 -1
- package/out/geo.d.ts +10 -13
- package/out/geo.d.ts.map +1 -1
- package/out/geo.js +15 -35
- package/out/geo.js.map +1 -1
- package/out/geonames-aliases.d.ts +23 -1
- package/out/geonames-aliases.d.ts.map +1 -1
- package/out/geonames-aliases.js +88 -33
- package/out/geonames-aliases.js.map +1 -1
- package/out/geonames-postal.d.ts +22 -1
- package/out/geonames-postal.d.ts.map +1 -1
- package/out/geonames-postal.js +50 -16
- package/out/geonames-postal.js.map +1 -1
- package/out/index.d.ts +2 -1
- package/out/index.d.ts.map +1 -1
- package/out/index.js +2 -1
- package/out/index.js.map +1 -1
- package/out/interpolation.d.ts.map +1 -1
- package/out/interpolation.js +2 -1
- package/out/interpolation.js.map +1 -1
- package/out/poi-lookup.d.ts +1 -1
- package/out/poi-lookup.js +3 -3
- package/out/reverse.d.ts.map +1 -1
- package/out/reverse.js +3 -9
- package/out/reverse.js.map +1 -1
- package/out/sqlite-convention-source.d.ts.map +1 -1
- package/out/sqlite-convention-source.js +4 -3
- package/out/sqlite-convention-source.js.map +1 -1
- package/out/street-morphology-fst-builder.d.ts.map +1 -1
- package/out/street-morphology-fst-builder.js +2 -1
- package/out/street-morphology-fst-builder.js.map +1 -1
- package/package.json +16 -6
- package/poi-lookup.ts +3 -3
- package/reverse.ts +4 -9
- package/sqlite-convention-source.ts +5 -3
- package/street-morphology-fst-builder.ts +3 -1
package/fst-freshness.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Does an `fst-*.bin` still match the gazetteer it was built from?
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS EXISTS. Every FST artifact is a projection of one WOF admin database, and the admin
|
|
9
|
+
* database is a sealed readonly artifact that a rebuild REPLACES. Nothing mechanically tied the two
|
|
10
|
+
* together: the 2026-08-04 admin swap (4.87M rows, ancestry repaired, macrohood/microhood ingested)
|
|
11
|
+
* left `fst-global-priority.bin` at its 2026-05-28 build and the per-locale set at 2026-07-26, and
|
|
12
|
+
* the only way to notice was to compare mtimes by hand. The artifacts kept loading, kept answering
|
|
13
|
+
* queries, and answered them from a gazetteer that no longer exists.
|
|
14
|
+
*
|
|
15
|
+
* `FSTProvenance` already recorded `sourceDB` — the source's PATH, which is exactly the field that
|
|
16
|
+
* cannot change when the bytes behind it do. So the stamp gains the source's IDENTITY (md5 + byte
|
|
17
|
+
* size) and this module compares it. The shape deliberately mirrors
|
|
18
|
+
* `scripts/weights-overlay-linker.ts`'s `pairIndexStaleReason`: one function returning a reason
|
|
19
|
+
* string or `undefined`, so a fact added to the stamp cannot be checked by some callers and not
|
|
20
|
+
* others — which is how three of the four base linkers ended up unable to notice a PIX1 schema bump.
|
|
21
|
+
*
|
|
22
|
+
* FORMAT IS PART OF FRESHNESS. The check compares the serializer version too, not just the source
|
|
23
|
+
* md5. A guard that checks only the source reads a format-obsolete binary as "current" (the R5
|
|
24
|
+
* freshness-guard lesson, format edition), and a file below {@link MIN_STAMPED_FORMAT_VERSION}
|
|
25
|
+
* cannot carry a stamp at all — reported as its own reason rather than silently passing.
|
|
26
|
+
*
|
|
27
|
+
* STALE IS A WARNING, NOT A FAULT. A dev tree with an old FST must still run; the artifact is a
|
|
28
|
+
* decode-time bias list, not a correctness dependency. Callers print {@link formatFSTStaleWarning}
|
|
29
|
+
* and continue.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto"
|
|
33
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs"
|
|
34
|
+
|
|
35
|
+
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
36
|
+
|
|
37
|
+
import { FST_FORMAT_VERSION } from "./fst-serialize.ts"
|
|
38
|
+
import type { FSTProvenance } from "./fst-types.ts"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fixed header size in bytes — mirrors `fst-serialize.ts`'s `HEADER_SIZE`. Duplicated rather than exported across
|
|
42
|
+
* because this module reads the header by SEEK (never buffering the file), and the serializer's constant is private to
|
|
43
|
+
* its own read/write pair.
|
|
44
|
+
*/
|
|
45
|
+
const HEADER_SIZE = 32
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `"FST\0"` little-endian, the four magic bytes every artifact opens with.
|
|
49
|
+
*/
|
|
50
|
+
const MAGIC = 0x00_54_53_46
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Byte offset of the u32 trailer offset inside the header (the last of its eight fields).
|
|
54
|
+
*/
|
|
55
|
+
const PROVENANCE_OFFSET_FIELD = 28
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* First serializer version carrying the trailing provenance block. Below this a file has no place to put a stamp, so
|
|
59
|
+
* "unstamped" is a statement about the FORMAT, not about the builder.
|
|
60
|
+
*/
|
|
61
|
+
export const MIN_STAMPED_FORMAT_VERSION = 3
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hex characters in an md5 digest.
|
|
65
|
+
*/
|
|
66
|
+
const MD5_HEX_LENGTH = 32
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bytes per read in {@link md5FileSync}. 8 MiB measured at 7.3 s for the 5.27 GB admin DB on the lab playpen — the same
|
|
70
|
+
* throughput as `md5sum(1)`, so the sidecar below is what makes the check cheap, not the chunk size.
|
|
71
|
+
*/
|
|
72
|
+
const MD5_CHUNK_BYTES = 8 * 1024 * 1024
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What an FST was built from, recorded so a later reader can tell whether that thing still exists.
|
|
76
|
+
*
|
|
77
|
+
* `bytes` is not redundant with `md5` — it is the field that survives a truncated or half-written source and makes the
|
|
78
|
+
* mismatch legible in the warning ("5,273,722,880 → 5,372,076,032" names the rebuild; a hex delta does not).
|
|
79
|
+
*/
|
|
80
|
+
export interface FSTSourceIdentity {
|
|
81
|
+
md5: string
|
|
82
|
+
bytes: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The stamp fields a freshness guard reads off an artifact, plus the format version it was written at.
|
|
87
|
+
*
|
|
88
|
+
* Every field is optional because every one of them can be legitimately absent on an artifact that predates it, and the
|
|
89
|
+
* meaning-of-zero rule applies to all of them: absent is a distinct state from "matches", and the reasons below say so
|
|
90
|
+
* in different words.
|
|
91
|
+
*/
|
|
92
|
+
export interface FSTStampFields {
|
|
93
|
+
formatVersion: number
|
|
94
|
+
provenance: FSTProvenance | undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What a caller expects the artifact to have been built from.
|
|
99
|
+
*
|
|
100
|
+
* `exclusionPolicy` is optional and caller-supplied on purpose. The policy id lives in
|
|
101
|
+
* `mailwoman/gazetteer-pipeline/fst.ts` (which depends on this package, not the other way round), so only the caller
|
|
102
|
+
* knows which policy it means — the same split as the pair-index guard, where the format+magnitude half is shared and
|
|
103
|
+
* the source-md5 half stays with the script that knows its sources.
|
|
104
|
+
*/
|
|
105
|
+
export interface FSTExpectation {
|
|
106
|
+
source: FSTSourceIdentity
|
|
107
|
+
/**
|
|
108
|
+
* Serializer version to require. Defaults to {@link FST_FORMAT_VERSION} — the version this tree writes — so a caller
|
|
109
|
+
* cannot forget the format half of the comparison. Override only to check against a specific older floor.
|
|
110
|
+
*/
|
|
111
|
+
formatVersion?: number
|
|
112
|
+
exclusionPolicy?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read an artifact's stamp WITHOUT deserializing it — a header seek plus the trailer, three reads totalling a few
|
|
117
|
+
* kilobytes. The distinction matters: `fst-global-priority.bin` is 317 MB and this runs on every `yarn test` via the
|
|
118
|
+
* weights linkers, so `readFileSync` + `readFSTProvenance` would trade a freshness guard for a slower test suite and
|
|
119
|
+
* nobody would keep it.
|
|
120
|
+
*
|
|
121
|
+
* Returns `undefined` for a file that is absent, too small, or not an FST at all — none of which is this function's
|
|
122
|
+
* business to diagnose.
|
|
123
|
+
*/
|
|
124
|
+
export function peekFSTStampFields(path: string): FSTStampFields | undefined {
|
|
125
|
+
if (!existsSync(path)) return undefined
|
|
126
|
+
const size = statSync(path).size
|
|
127
|
+
|
|
128
|
+
if (size < HEADER_SIZE) return undefined
|
|
129
|
+
const fd = openSync(path, "r")
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const header = Buffer.alloc(HEADER_SIZE)
|
|
133
|
+
readSync(fd, header, 0, HEADER_SIZE, 0)
|
|
134
|
+
|
|
135
|
+
if (header.readUInt32LE(0) !== MAGIC) return undefined
|
|
136
|
+
const formatVersion = header.readUInt16LE(4)
|
|
137
|
+
|
|
138
|
+
if (formatVersion < MIN_STAMPED_FORMAT_VERSION) return { formatVersion, provenance: undefined }
|
|
139
|
+
const trailerStart = header.readUInt32LE(PROVENANCE_OFFSET_FIELD)
|
|
140
|
+
|
|
141
|
+
// 0 = "this build wrote no trailer" (the serializer's own encoding); anything past EOF is a
|
|
142
|
+
// truncated file. Both read as "no stamp", which is what the caller does with them anyway.
|
|
143
|
+
if (trailerStart === 0 || trailerStart + 4 > size) return { formatVersion, provenance: undefined }
|
|
144
|
+
|
|
145
|
+
const lengthBytes = Buffer.alloc(4)
|
|
146
|
+
readSync(fd, lengthBytes, 0, 4, trailerStart)
|
|
147
|
+
const jsonLength = lengthBytes.readUInt32LE(0)
|
|
148
|
+
|
|
149
|
+
if (jsonLength === 0 || trailerStart + 4 + jsonLength > size) return { formatVersion, provenance: undefined }
|
|
150
|
+
|
|
151
|
+
const json = Buffer.alloc(jsonLength)
|
|
152
|
+
readSync(fd, json, 0, jsonLength, trailerStart + 4)
|
|
153
|
+
|
|
154
|
+
return { formatVersion, provenance: tryParsingJSON<FSTProvenance>(json.toString("utf8")) ?? undefined }
|
|
155
|
+
} finally {
|
|
156
|
+
closeSync(fd)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Streaming MD5 of a file, SYNCHRONOUS.
|
|
162
|
+
*
|
|
163
|
+
* The async `md5File` in `@mailwoman/core/utils` is the one to reach for anywhere else. This exists because the FST
|
|
164
|
+
* builder and its whole call chain are synchronous by design (`buildFSTFromWOF` → `buildLocaleFSTs`), and making them
|
|
165
|
+
* async to stamp a checksum would cascade through the Pastel commands and the tests for one hash. It reads in
|
|
166
|
+
* {@link MD5_CHUNK_BYTES} chunks rather than `readFileSync` — the source is a multi-gigabyte database.
|
|
167
|
+
*/
|
|
168
|
+
export function md5FileSync(path: string): string {
|
|
169
|
+
const hash = createHash("md5")
|
|
170
|
+
const fd = openSync(path, "r")
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const chunk = Buffer.alloc(MD5_CHUNK_BYTES)
|
|
174
|
+
|
|
175
|
+
for (;;) {
|
|
176
|
+
const read = readSync(fd, chunk, 0, MD5_CHUNK_BYTES, null)
|
|
177
|
+
|
|
178
|
+
if (read <= 0) break
|
|
179
|
+
hash.update(chunk.subarray(0, read))
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(fd)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return hash.digest("hex")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The source identity an FST build should stamp, or a check should compare against.
|
|
190
|
+
*
|
|
191
|
+
* Uses the `.md5` sidecar convention the weights linkers already established on this exact file: `<path>.md5` in
|
|
192
|
+
* md5sum(1) format (`<hash> <filename>`), trusted only while its mtime is at least the source's. An older sidecar is
|
|
193
|
+
* recomputed. Without it the admin DB costs 7.3 s per call and the guard would be quietly disabled by whoever noticed
|
|
194
|
+
* `yarn test` got slower.
|
|
195
|
+
*
|
|
196
|
+
* `refreshSidecar` writes the recomputed digest back. Best-effort: a sealed data root or a read-only mount fails the
|
|
197
|
+
* write and the caller still gets its answer, because refusing to check freshness on a read-only tree would be the
|
|
198
|
+
* wrong trade.
|
|
199
|
+
*/
|
|
200
|
+
export function readWOFSourceIdentity(path: string, { refreshSidecar = true } = {}): FSTSourceIdentity {
|
|
201
|
+
const stats = statSync(path)
|
|
202
|
+
const memoKey = `${path}\0${stats.mtimeMs}\0${stats.size}`
|
|
203
|
+
const hit = sourceIdentityMemo.get(memoKey)
|
|
204
|
+
|
|
205
|
+
if (hit) return hit
|
|
206
|
+
const sidecarPath = `${path}.md5`
|
|
207
|
+
let md5: string | undefined
|
|
208
|
+
|
|
209
|
+
if (existsSync(sidecarPath)) {
|
|
210
|
+
const sidecarStats = statSync(sidecarPath)
|
|
211
|
+
|
|
212
|
+
if (sidecarStats.mtimeMs >= stats.mtimeMs) {
|
|
213
|
+
const [hash] = readFileSync(sidecarPath, "utf8").trim().split(/\s+/)
|
|
214
|
+
|
|
215
|
+
if (hash && hash.length === MD5_HEX_LENGTH) {
|
|
216
|
+
md5 = hash
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!md5) {
|
|
222
|
+
md5 = md5FileSync(path)
|
|
223
|
+
|
|
224
|
+
if (refreshSidecar) {
|
|
225
|
+
try {
|
|
226
|
+
writeFileSync(sidecarPath, `${md5} ${path.split("/").pop()}\n`)
|
|
227
|
+
} catch {
|
|
228
|
+
// Read-only data root — the digest is still correct, it just isn't cached.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const identity: FSTSourceIdentity = { md5, bytes: stats.size }
|
|
234
|
+
sourceIdentityMemo.set(memoKey, identity)
|
|
235
|
+
|
|
236
|
+
return identity
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Memo for {@link readWOFSourceIdentity}, keyed on (path, mtimeMs, size) — NOT on path alone, for the same reason
|
|
241
|
+
* `computeSurfaceCountryCounts` isn't: the admin DB is a sealed artifact that a rebuild REPLACES, so a path-only memo
|
|
242
|
+
* would serve a stale digest against a new file for the life of the process.
|
|
243
|
+
*/
|
|
244
|
+
const sourceIdentityMemo = new Map<string, FSTSourceIdentity>()
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Why an FST artifact is stale against `expected`, or `undefined` when it still matches.
|
|
248
|
+
*
|
|
249
|
+
* The order is deliberate: FORMAT first (a version-obsolete file is stale whatever its source says), then the presence
|
|
250
|
+
* of a stamp, then the source identity, then the build policy. Each returns prose a reader can act on — the reasons are
|
|
251
|
+
* printed verbatim into {@link formatFSTStaleWarning}.
|
|
252
|
+
*/
|
|
253
|
+
export function fstStaleReason(fields: FSTStampFields | undefined, expected: FSTExpectation): string | undefined {
|
|
254
|
+
if (!fields) return "unreadable or not an FST artifact"
|
|
255
|
+
const requiredFormat = expected.formatVersion ?? FST_FORMAT_VERSION
|
|
256
|
+
|
|
257
|
+
if (fields.formatVersion < requiredFormat) {
|
|
258
|
+
return `format v${fields.formatVersion} → v${requiredFormat}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (fields.formatVersion < MIN_STAMPED_FORMAT_VERSION) {
|
|
262
|
+
return `format v${fields.formatVersion} predates the build stamp (needs v${MIN_STAMPED_FORMAT_VERSION}+)`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const provenance = fields.provenance
|
|
266
|
+
|
|
267
|
+
if (!provenance) return "carries no build stamp"
|
|
268
|
+
|
|
269
|
+
if (!provenance.sourceDBMD5) {
|
|
270
|
+
return `built ${provenance.builtAt} with no source checksum — rebuild to stamp one`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (provenance.sourceDBMD5 !== expected.source.md5) {
|
|
274
|
+
return `source db ${provenance.sourceDBMD5.slice(0, 8)} → ${expected.source.md5.slice(0, 8)} (built ${provenance.builtAt})`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Reached only when the md5s agree, so a size disagreement means one of the two was recorded
|
|
278
|
+
// against a different file than it was hashed from. Cheap to check, and it never fires by accident.
|
|
279
|
+
if (provenance.sourceDBBytes !== undefined && provenance.sourceDBBytes !== expected.source.bytes) {
|
|
280
|
+
return `source db size ${provenance.sourceDBBytes} → ${expected.source.bytes} at a matching md5 — one of the two is misrecorded`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (expected.exclusionPolicy !== undefined && provenance.exclusionPolicy !== expected.exclusionPolicy) {
|
|
284
|
+
return `exclusion policy ${provenance.exclusionPolicy ?? "(none)"} → ${expected.exclusionPolicy}`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The whole check, for a caller that has a path and a source DB and wants a warning string or nothing.
|
|
292
|
+
*
|
|
293
|
+
* Returns `undefined` when the artifact is current OR when it is absent — an absent artifact is a different problem
|
|
294
|
+
* with a different message, and every existing caller already reports it in place.
|
|
295
|
+
*/
|
|
296
|
+
export function fstFreshnessWarning({
|
|
297
|
+
fstPath,
|
|
298
|
+
sourceDBPath,
|
|
299
|
+
formatVersion,
|
|
300
|
+
exclusionPolicy,
|
|
301
|
+
rebuildCommand,
|
|
302
|
+
}: {
|
|
303
|
+
fstPath: string
|
|
304
|
+
sourceDBPath: string
|
|
305
|
+
formatVersion?: number
|
|
306
|
+
exclusionPolicy?: string
|
|
307
|
+
rebuildCommand: string
|
|
308
|
+
}): string | undefined {
|
|
309
|
+
if (!existsSync(fstPath) || !existsSync(sourceDBPath)) return undefined
|
|
310
|
+
|
|
311
|
+
const reason = fstStaleReason(peekFSTStampFields(fstPath), {
|
|
312
|
+
source: readWOFSourceIdentity(sourceDBPath),
|
|
313
|
+
...(formatVersion === undefined ? {} : { formatVersion }),
|
|
314
|
+
...(exclusionPolicy === undefined ? {} : { exclusionPolicy }),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
return reason === undefined ? undefined : formatFSTStaleWarning({ fstPath, reason, rebuildCommand })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The one warning format, so `grep -r "FST STALE"` finds every site that can emit one.
|
|
322
|
+
*/
|
|
323
|
+
export function formatFSTStaleWarning({
|
|
324
|
+
fstPath,
|
|
325
|
+
reason,
|
|
326
|
+
rebuildCommand,
|
|
327
|
+
}: {
|
|
328
|
+
fstPath: string
|
|
329
|
+
reason: string
|
|
330
|
+
rebuildCommand: string
|
|
331
|
+
}): string {
|
|
332
|
+
return `WARNING: FST STALE — ${fstPath}: ${reason}. Rebuild with: ${rebuildCommand}`
|
|
333
|
+
}
|
package/fst-serialize.ts
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* (V2); was population u32 (V1) lat f32 lon f32 chain [u32; 8] parent chain (unused slots = 0)
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
27
|
+
|
|
26
28
|
import type { FSTNode } from "./fst-matcher.ts"
|
|
27
29
|
import { FSTMatcher } from "./fst-matcher.ts"
|
|
28
30
|
import type { FSTProvenance, PlaceEntry, PlacetypeID } from "./fst-types.ts"
|
|
@@ -58,6 +60,13 @@ const MAGIC = Buffer.from("FST\0", "ascii")
|
|
|
58
60
|
*/
|
|
59
61
|
const VERSION = 4
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The format version this tree WRITES, published so a freshness guard can call an older artifact format-stale without
|
|
65
|
+
* re-typing the number. Mirrors `REQUIRED_PAIR_INDEX_SCHEMA`'s role for PIX1: one constant, so a version bump cannot be
|
|
66
|
+
* noticed by some checkers and not others. See `fst-freshness.ts`.
|
|
67
|
+
*/
|
|
68
|
+
export const FST_FORMAT_VERSION = VERSION
|
|
69
|
+
|
|
61
70
|
/**
|
|
62
71
|
* Fixed header size in bytes: magic, version, and the section offsets that follow it.
|
|
63
72
|
*/
|
|
@@ -378,7 +387,7 @@ export function readFSTProvenance(buf: Buffer): FSTProvenance | undefined {
|
|
|
378
387
|
const jsonLen = buf.readUInt32LE(provenanceOffset)
|
|
379
388
|
const jsonStr = buf.toString("utf8", provenanceOffset + 4, provenanceOffset + 4 + jsonLen)
|
|
380
389
|
|
|
381
|
-
return
|
|
390
|
+
return tryParsingJSON<FSTProvenance>(jsonStr) ?? undefined
|
|
382
391
|
} catch {
|
|
383
392
|
return undefined
|
|
384
393
|
}
|
package/fst-types.ts
CHANGED
|
@@ -66,6 +66,23 @@ export interface FSTProvenance {
|
|
|
66
66
|
nameInsertions: number
|
|
67
67
|
importanceMatches: number
|
|
68
68
|
sourceDB?: string
|
|
69
|
+
/**
|
|
70
|
+
* MD5 of the source database's bytes at build time — the artifact's link to the gazetteer it is a projection of.
|
|
71
|
+
*
|
|
72
|
+
* `sourceDB` records the PATH, which is exactly the field that cannot change when the bytes behind it do: the admin
|
|
73
|
+
* DB is a sealed readonly artifact that a rebuild REPLACES in place, so every FST built before the 2026-08-04 swap
|
|
74
|
+
* still names the current file and none of them was built from it. Compared by `fst-freshness.ts`.
|
|
75
|
+
*
|
|
76
|
+
* `undefined` = built before the stamp existed (every artifact predating 2026-08-05). NEVER conflate that with "built
|
|
77
|
+
* from a database whose md5 is unknown" — the freshness check reports the two in different words.
|
|
78
|
+
*/
|
|
79
|
+
sourceDBMD5?: string
|
|
80
|
+
/**
|
|
81
|
+
* Byte size of the source database at build time. Not redundant with {@link FSTProvenance.sourceDBMD5}: it survives a
|
|
82
|
+
* truncated source and it is what makes a staleness warning legible — "5,273,722,880 → 5,372,076,032" names the
|
|
83
|
+
* rebuild, a hex delta does not.
|
|
84
|
+
*/
|
|
85
|
+
sourceDBBytes?: number
|
|
69
86
|
modelCardVersion?: string
|
|
70
87
|
/**
|
|
71
88
|
* Degenerate-surface curation policy applied at build time (absent = uncurated build).
|
|
@@ -79,6 +96,12 @@ export interface FSTProvenance {
|
|
|
79
96
|
|
|
80
97
|
export interface BuildFSTOpts {
|
|
81
98
|
dbPath: string
|
|
99
|
+
/**
|
|
100
|
+
* Pre-computed identity of `dbPath` to stamp into provenance. Omit and the builder reads it via
|
|
101
|
+
* `readWOFSourceIdentity` (sidecar-cached). Supply it when the digest is already in hand, or when the caller is
|
|
102
|
+
* building from something whose identity it defines itself.
|
|
103
|
+
*/
|
|
104
|
+
sourceIdentity?: { md5: string; bytes: number }
|
|
82
105
|
countries?: string[]
|
|
83
106
|
placetypes?: PlacetypeID[]
|
|
84
107
|
languages?: string[]
|
package/geo.ts
CHANGED
|
@@ -10,17 +10,20 @@
|
|
|
10
10
|
* ray-cast PIP a ~30-line one — both plenty fast for the post-fetch passes (we operate on ≤ a few
|
|
11
11
|
* hundred candidates per query, not the whole 142k-row corpus).
|
|
12
12
|
*
|
|
13
|
-
* The
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
13
|
+
* The even-odd ray cast lives in `@mailwoman/spatial` and is re-exported below, so the resolver,
|
|
14
|
+
* the reverse-geocoder and the gazetteer pipeline share one definition. `scripts/eval/pip-containment.py`
|
|
15
|
+
* grades the same containment truth and has to be matched BY HAND if the algorithm changes — it is
|
|
16
|
+
* the one copy no import can reach.
|
|
17
17
|
*
|
|
18
18
|
* The R*Tree index name + schema are centralized in `fts.ts` (alongside the FTS5 build).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
import { pointInPolygon } from "@mailwoman/spatial"
|
|
22
|
+
|
|
23
|
+
// haversineKm and the point-in-polygon ray cast are implemented in @mailwoman/spatial, the math
|
|
24
|
+
// home; re-exported so this package's readers keep importing them from "./geo.ts" (the spatial dep
|
|
25
|
+
// is transitive via @mailwoman/resolver).
|
|
26
|
+
export { haversineKm, pointInRing } from "@mailwoman/spatial"
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* WGS-84 degrees → radians.
|
|
@@ -82,42 +85,14 @@ export interface GeojsonMultiPolygon {
|
|
|
82
85
|
export type GeojsonGeometry = GeojsonPolygon | GeojsonMultiPolygon | { type: string; coordinates?: unknown }
|
|
83
86
|
|
|
84
87
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
let inside = false
|
|
91
|
-
const n = ring.length
|
|
92
|
-
|
|
93
|
-
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
94
|
-
const xi = ring[i]![0]
|
|
95
|
-
const yi = ring[i]![1]
|
|
96
|
-
const xj = ring[j]![0]
|
|
97
|
-
const yj = ring[j]![1]
|
|
98
|
-
|
|
99
|
-
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
|
100
|
-
inside = !inside
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return inside
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Even-odd containment over a polygon's ring list (`[outer, hole1, …]`) — being inside an odd number of rings means
|
|
109
|
-
* inside the polygon, which handles holes without ring-orientation rules.
|
|
88
|
+
* Even-odd containment over a polygon's ring list (`[outer, hole1, …]`).
|
|
89
|
+
*
|
|
90
|
+
* Named alias for `@mailwoman/spatial`'s {@link pointInPolygon}, kept because this package's readers and the gazetteer
|
|
91
|
+
* pipeline import `pointInPolygonRings` by name and the "rings" spelling is what makes the ring-list argument obvious
|
|
92
|
+
* at those call sites.
|
|
110
93
|
*/
|
|
111
94
|
export function pointInPolygonRings(lon: number, lat: number, rings: readonly GeojsonPosition[][]): boolean {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const ring of rings) {
|
|
115
|
-
if (pointInRing(lon, lat, ring)) {
|
|
116
|
-
inside = !inside
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return inside
|
|
95
|
+
return pointInPolygon(lon, lat, rings)
|
|
121
96
|
}
|
|
122
97
|
|
|
123
98
|
/**
|