@mailwoman/bdc 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.
- package/lib/env.ts +30 -0
- package/lib/index.ts +2 -0
- package/{sdk → lib/sdk}/build-bdc.ts +343 -298
- package/{sdk → lib/sdk}/client.ts +24 -38
- package/{sdk → lib/sdk}/common.ts +1 -1
- package/{sdk → lib/sdk}/data-collection.ts +2 -2
- package/{sdk → lib/sdk}/download.ts +19 -15
- package/{sdk → lib/sdk}/filing-dates.ts +3 -3
- package/{sdk → lib/sdk}/filing-landscape.ts +12 -24
- package/lib/sdk/index.ts +14 -0
- package/{sdk → lib/sdk}/list-files.ts +2 -2
- package/{sdk → lib/sdk}/nearest-infrastructure.ts +5 -5
- package/lib/sdk/parsing.ts +157 -0
- package/{sdk → lib/sdk}/plausibility.ts +43 -29
- package/out/env.d.ts +24 -0
- package/out/env.d.ts.map +1 -0
- package/out/env.js +28 -0
- package/out/env.js.map +1 -0
- package/out/index.d.ts +2 -2
- package/out/index.d.ts.map +1 -1
- package/out/index.js +2 -2
- package/out/index.js.map +1 -1
- package/out/schema.d.ts.map +1 -1
- package/out/schema.js.map +1 -1
- package/out/sdk/build-bdc.d.ts +32 -25
- package/out/sdk/build-bdc.d.ts.map +1 -1
- package/out/sdk/build-bdc.js +255 -219
- package/out/sdk/build-bdc.js.map +1 -1
- package/out/sdk/buildings.d.ts.map +1 -1
- package/out/sdk/buildings.js.map +1 -1
- package/out/sdk/client.d.ts +7 -7
- package/out/sdk/client.d.ts.map +1 -1
- package/out/sdk/client.js +17 -29
- package/out/sdk/client.js.map +1 -1
- package/out/sdk/common.d.ts +1 -1
- package/out/sdk/common.d.ts.map +1 -1
- package/out/sdk/common.js.map +1 -1
- package/out/sdk/data-collection.d.ts +2 -2
- package/out/sdk/data-collection.d.ts.map +1 -1
- package/out/sdk/data-collection.js.map +1 -1
- package/out/sdk/download.d.ts +4 -3
- package/out/sdk/download.d.ts.map +1 -1
- package/out/sdk/download.js +12 -13
- package/out/sdk/download.js.map +1 -1
- package/out/sdk/filing-dates.d.ts +3 -3
- package/out/sdk/filing-dates.d.ts.map +1 -1
- package/out/sdk/filing-dates.js +1 -1
- package/out/sdk/filing-dates.js.map +1 -1
- package/out/sdk/filing-landscape.d.ts +4 -4
- package/out/sdk/filing-landscape.d.ts.map +1 -1
- package/out/sdk/filing-landscape.js +9 -19
- package/out/sdk/filing-landscape.js.map +1 -1
- package/out/sdk/index.d.ts +14 -14
- package/out/sdk/index.d.ts.map +1 -1
- package/out/sdk/index.js +14 -14
- package/out/sdk/index.js.map +1 -1
- package/out/sdk/list-files.d.ts +2 -2
- package/out/sdk/list-files.d.ts.map +1 -1
- package/out/sdk/list-files.js +1 -1
- package/out/sdk/list-files.js.map +1 -1
- package/out/sdk/location.d.ts.map +1 -1
- package/out/sdk/location.js.map +1 -1
- package/out/sdk/nearest-infrastructure.d.ts +4 -4
- package/out/sdk/nearest-infrastructure.d.ts.map +1 -1
- package/out/sdk/nearest-infrastructure.js +2 -2
- package/out/sdk/nearest-infrastructure.js.map +1 -1
- package/out/sdk/parsing.d.ts +66 -28
- package/out/sdk/parsing.d.ts.map +1 -1
- package/out/sdk/parsing.js +103 -70
- package/out/sdk/parsing.js.map +1 -1
- package/out/sdk/plausibility.d.ts +32 -14
- package/out/sdk/plausibility.d.ts.map +1 -1
- package/out/sdk/plausibility.js +15 -20
- package/out/sdk/plausibility.js.map +1 -1
- package/out/sdk/technologies.d.ts.map +1 -1
- package/out/sdk/technologies.js.map +1 -1
- package/package.json +164 -22
- package/index.ts +0 -2
- package/sdk/index.ts +0 -14
- package/sdk/parsing.ts +0 -124
- /package/{schema.ts → lib/schema.ts} +0 -0
- /package/{sdk → lib/sdk}/buildings.ts +0 -0
- /package/{sdk → lib/sdk}/location.ts +0 -0
- /package/{sdk → lib/sdk}/technologies.ts +0 -0
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Mirrors `mailwoman/gazetteer-pipeline/poi/build-poi.ts`'s shape closely: the same build-tuning
|
|
10
10
|
* pragmas, the same single-pass `Map<number, number>` coverage aggregation taken during the load
|
|
11
|
-
* (no second scan), the same
|
|
12
|
-
* `@mailwoman/core/layers` calls, and the same `writeLayerManifest` → `sealDatabase` tail.
|
|
11
|
+
* (no second scan), and the same `writeLayerManifest` → `sealDatabase` tail.
|
|
13
12
|
*
|
|
14
13
|
* Two differences from that precedent, both deliberate:
|
|
15
14
|
*
|
|
@@ -51,32 +50,33 @@
|
|
|
51
50
|
* the house rule exists for.
|
|
52
51
|
*/
|
|
53
52
|
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
import { DatabaseSync } from "node:sqlite"
|
|
58
|
-
|
|
59
|
-
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
53
|
+
import { pathExists, readDirectory, readFileRange } from "@mailwoman/core/fs/readers"
|
|
54
|
+
import { removePathIfPresent, movePath, makeDirectories, removePath } from "@mailwoman/core/fs/writers"
|
|
55
|
+
import { tryParsingJSON } from "@mailwoman/core/json"
|
|
60
56
|
import {
|
|
61
57
|
createLayerCoverageTable,
|
|
62
58
|
createLayerManifestTable,
|
|
63
59
|
LayerFreshnessPolicy,
|
|
64
60
|
LayerTier,
|
|
61
|
+
sourcePresentCoverageCells,
|
|
65
62
|
writeLayerCoverage,
|
|
66
63
|
writeLayerManifest,
|
|
67
|
-
type LayerContractDatabase,
|
|
68
64
|
} from "@mailwoman/core/layers"
|
|
69
|
-
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
70
|
-
import { openBuiltDatabase, sealDatabase } from "@mailwoman/core/utils"
|
|
71
65
|
import type { FilerDatabase } from "@mailwoman/filer"
|
|
72
|
-
// `pickPrimaryFRN`/`readFRNFilingCandidates` are loaded via a LAZY `await import("@mailwoman/filer/
|
|
66
|
+
// `pickPrimaryFRN`/`readFRNFilingCandidates` are loaded via a LAZY `await import("@mailwoman/filer/filer-lookup")`
|
|
73
67
|
// inside `populateBDCProviderTable`, not a top-level runtime import — see that function's docstring
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
import type { FRN
|
|
68
|
+
//
|
|
69
|
+
// Only the TYPES are imported here; `import type` is fully erased.
|
|
70
|
+
import type { FRN } from "@mailwoman/filer/frn"
|
|
71
|
+
import type { ProviderListRow } from "@mailwoman/filer/sdk"
|
|
77
72
|
import { shortCellToInt, type H3Cell } from "@mailwoman/spatial"
|
|
73
|
+
import { beginBatched } from "@mailwoman/sqlite/batched"
|
|
74
|
+
import { DatabaseClient } from "@mailwoman/sqlite/client"
|
|
75
|
+
import { openBuiltClient } from "@mailwoman/sqlite/sealed"
|
|
76
|
+
import { sealDatabase, swapDatabaseIntoPlace } from "@mailwoman/sqlite/sealed-db"
|
|
78
77
|
import { cellToParent, latLngToCell } from "h3-js"
|
|
79
78
|
import type { Insertable, Kysely } from "kysely"
|
|
79
|
+
import { basename, dirname, join } from "path-ts"
|
|
80
80
|
|
|
81
81
|
import {
|
|
82
82
|
BDC_COVERAGE_H3_RESOLUTION,
|
|
@@ -86,9 +86,9 @@ import {
|
|
|
86
86
|
createBDCProviderTable,
|
|
87
87
|
type BDCDatabase,
|
|
88
88
|
type BDCProviderTable,
|
|
89
|
-
} from "
|
|
90
|
-
import type { ProviderID } from "
|
|
91
|
-
import {
|
|
89
|
+
} from "#schema"
|
|
90
|
+
import type { ProviderID } from "#sdk/common"
|
|
91
|
+
import { readAvailabilityRows, type BDCAvailabilityRow } from "#sdk/parsing"
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
* Rows committed per `BEGIN`/`COMMIT` batch during both the staging load and the materialize pass — matches
|
|
@@ -106,8 +106,8 @@ export const BDC_ATTRIBUTION =
|
|
|
106
106
|
|
|
107
107
|
export interface BuildBDCOptions {
|
|
108
108
|
/**
|
|
109
|
-
* Injected row source — the
|
|
110
|
-
* filesystem read happens.
|
|
109
|
+
* Injected row source — the TEST INJECTION POINT (mirrors `BuildPOIOptions.rows`). When given, `csvPaths` is ignored
|
|
110
|
+
* and no filesystem read happens.
|
|
111
111
|
*/
|
|
112
112
|
rows?: Iterable<BDCAvailabilityRow> | AsyncIterable<BDCAvailabilityRow>
|
|
113
113
|
/**
|
|
@@ -139,15 +139,15 @@ export interface BuildBDCOptions {
|
|
|
139
139
|
* Resolve a 15-char census block GEOID to its centroid. Injected so tests supply a small fixture `Map` lookup instead
|
|
140
140
|
* of touching a real TIGER database; the real (CLI-wired) implementation is
|
|
141
141
|
* {@linkcode createTIGERBlockCentroidLookup}, which reads `tabblock20.GEOID` (uppercase — `TIGERBlockTable`) block
|
|
142
|
-
* geometry. Returning `undefined` for an unknown geoid is
|
|
143
|
-
*
|
|
142
|
+
* geometry. Returning `undefined` for an unknown geoid is required: the materialize pass counts it in `unknownGeoids`
|
|
143
|
+
* and skips the row — it must NEVER guess a cell.
|
|
144
144
|
*/
|
|
145
145
|
blockCentroids: (geoid: string) => { lat: number; lon: number } | undefined
|
|
146
146
|
onProgress?: (message: string) => void
|
|
147
147
|
/**
|
|
148
|
-
* Provider-list rows ({@link ProviderListRow}, `@mailwoman/filer/sdk`'s `parseProviderList`) — the test/CLI
|
|
149
|
-
* populating `bdc_provider` (2a decision 8 / 3a decision 6). When ABSENT (the default), `bdc_provider`
|
|
150
|
-
* and the rest of the build is untouched: every code path this option touches is
|
|
148
|
+
* Provider-list rows ({@link ProviderListRow}, `@mailwoman/filer/sdk`'s `parseProviderList`) — the test/CLI injection
|
|
149
|
+
* point for populating `bdc_provider` (2a decision 8 / 3a decision 6). When ABSENT (the default), `bdc_provider`
|
|
150
|
+
* stays empty and the rest of the build is untouched: every code path this option touches is conditioned behind `if
|
|
151
151
|
* (options.providers)`, so omitting it changes nothing. When present, `buildBDCDatabase` groups rows by `providerID`
|
|
152
152
|
* and inserts one `bdc_provider` row per distinct provider — see {@link BuildBDCOptions.filerDB} for how the primary
|
|
153
153
|
* FRN is picked when a provider carries more than one, and `schema.ts`'s `BDCProviderTable` docstring for the full
|
|
@@ -206,16 +206,6 @@ export interface BuildBDCResult {
|
|
|
206
206
|
providersPopulated: number
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
/**
|
|
210
|
-
* `BDCDatabase extends LayerContractDatabase` structurally, but Kysely's `transaction()` makes `Kysely<DB>` INVARIANT
|
|
211
|
-
* in `DB` — narrows a `DatabaseClient<BDCDatabase>` handle back down for the `@mailwoman/core/layers` calls. Exact
|
|
212
|
-
* precedent: `build-poi.ts`'s own `asContractDB`; see that file for the full rationale (tried widening the shared
|
|
213
|
-
* package's signatures first — breaks THEIR internal `insertInto`/`selectFrom` calls instead).
|
|
214
|
-
*/
|
|
215
|
-
function asContractDB(kdb: DatabaseClient<BDCDatabase>): DatabaseClient<LayerContractDatabase> {
|
|
216
|
-
return kdb as unknown as DatabaseClient<LayerContractDatabase>
|
|
217
|
-
}
|
|
218
|
-
|
|
219
209
|
/**
|
|
220
210
|
* Create the build-only `bdc_stage` table — deliberately NOT part of the public {@link BDCDatabase} interface (it's
|
|
221
211
|
* dropped before the artifact seals, so it never appears in the shipped schema). Built via Kysely's schema builder per
|
|
@@ -268,7 +258,7 @@ interface BDCStageRow {
|
|
|
268
258
|
*
|
|
269
259
|
* `csvPath` is optional and used ONLY to name the offending file in a thrown error (the direct-buffer unit tests call
|
|
270
260
|
* this without one; {@linkcode readAvailabilityRowsFromCSVPaths} always supplies it). The `Number.isSafeInteger` guard
|
|
271
|
-
* below is
|
|
261
|
+
* below is required, not defensive dressing: `bdc_stage.provider_id` is `INTEGER NOT NULL`, and a bare
|
|
272
262
|
* `Number.parseInt` on a non-numeric field (a malformed/re-headered/truncated CSV) silently produces `NaN`. `NaN` binds
|
|
273
263
|
* to that NOT NULL column as SQLite `NULL`, `INSERT OR IGNORE` then drops the row without a constraint error, and every
|
|
274
264
|
* dropped row gets counted as `deduped` — the ENTIRE file's rows vanish silently, misreported as ordinary dedup. A
|
|
@@ -308,17 +298,26 @@ export function peekProviderID(csvBuffer: Buffer, csvPath?: string): ProviderID
|
|
|
308
298
|
}
|
|
309
299
|
|
|
310
300
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
301
|
+
* Bytes read to peek the `provider_id`. Only the header row plus the first data row are needed and an FCC availability
|
|
302
|
+
* row is ~110 bytes, so this is three orders of magnitude of slack. A file shorter than this simply reads short —
|
|
303
|
+
* {@linkcode peekProviderID} already reports a header-only or empty file by message.
|
|
304
|
+
*
|
|
305
|
+
* `provider_id` is a constant per file, so establishing it needs the first data row and nothing else; a whole-file read
|
|
306
|
+
* was resident-loading 920 MB (one state × technology) to read one column of one row.
|
|
307
|
+
*/
|
|
308
|
+
const PROVIDER_ID_PEEK_BYTES = 64 * 1024
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Peeks each file's `provider_id` off its head ({@linkcode peekProviderID}, passing the path through so a malformed
|
|
312
|
+
* file's error names it), then STREAMS every row via `readAvailabilityRows` — the file is never resident. This is the
|
|
313
|
+
* production counterpart to the TEST INJECTION POINT's injected `rows` — exercised by `build-bdc.test.ts` only for the
|
|
314
|
+
* malformed-provider-id rejection path, same as `build-poi.ts`'s Parquet reader.
|
|
315
315
|
*/
|
|
316
316
|
async function* readAvailabilityRowsFromCSVPaths(csvPaths: readonly string[]): AsyncIterable<BDCAvailabilityRow> {
|
|
317
317
|
for (const csvPath of csvPaths) {
|
|
318
|
-
const
|
|
319
|
-
const providerID = peekProviderID(buffer, csvPath)
|
|
318
|
+
const providerID = peekProviderID(await readFileRange(csvPath, 0, PROVIDER_ID_PEEK_BYTES), csvPath)
|
|
320
319
|
|
|
321
|
-
yield*
|
|
320
|
+
yield* readAvailabilityRows(csvPath, providerID)
|
|
322
321
|
}
|
|
323
322
|
}
|
|
324
323
|
|
|
@@ -336,11 +335,18 @@ interface GeoJSONMultiPolygon {
|
|
|
336
335
|
}
|
|
337
336
|
|
|
338
337
|
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
338
|
+
* Area-weighted (shoelace) centroid of a GeoJSON `Polygon`/`MultiPolygon`'s EXTERIOR ring(s), area-weighted across
|
|
339
|
+
* rings for a MultiPolygon. Interior rings/holes are still ignored — a hole moves a block's centroid far less than the
|
|
340
|
+
* vertex-density skew this replaces, and only 1.0% of measured blocks carry one.
|
|
341
|
+
*
|
|
342
|
+
* This REPLACED the first version's vertex-average, whose "same res-9 cell for all but pathological shapes" claim was
|
|
343
|
+
* falsified by measurement over every real TIGER 2020 block in LA + Orange county (118,360 blocks, 2026-08-11): the
|
|
344
|
+
* vertex-average landed in a different res-9 cell for 11.6% of blocks, p99 displacement 286 m (past the ~174 m cell
|
|
345
|
+
* edge), max 3.7 km — the tail is TIGER's elongated rural/mountain blocks, whose boundary vertices cluster on the
|
|
346
|
+
* squiggly natural edge and drag a vertex-average toward it.
|
|
347
|
+
*
|
|
348
|
+
* A degenerate geometry with zero total ring area (a sliver the shoelace annihilates) falls back to the vertex average
|
|
349
|
+
* — a weaker answer beats none, and the fallback is exactly the old behavior.
|
|
344
350
|
*
|
|
345
351
|
* Returns `undefined` for anything that doesn't parse as one of the two geometry types (including `null` geometry).
|
|
346
352
|
*/
|
|
@@ -358,21 +364,49 @@ export function geometryCentroid(geometryJSON: string | null): { lat: number; lo
|
|
|
358
364
|
? geometry.coordinates.map((polygon) => polygon[0] ?? [])
|
|
359
365
|
: []
|
|
360
366
|
|
|
367
|
+
let totalArea = 0
|
|
368
|
+
let weightedLon = 0
|
|
369
|
+
let weightedLat = 0
|
|
361
370
|
let sumLon = 0
|
|
362
371
|
let sumLat = 0
|
|
363
372
|
let count = 0
|
|
364
373
|
|
|
365
374
|
for (const ring of exteriorRings) {
|
|
366
|
-
|
|
367
|
-
|
|
375
|
+
let ringArea = 0
|
|
376
|
+
let ringLon = 0
|
|
377
|
+
let ringLat = 0
|
|
378
|
+
|
|
379
|
+
for (let i = 0; i < ring.length - 1; i++) {
|
|
380
|
+
const [x1, y1] = ring[i]!
|
|
381
|
+
const [x2, y2] = ring[i + 1]!
|
|
368
382
|
|
|
369
|
-
if (typeof
|
|
383
|
+
if (typeof x1 !== "number" || typeof y1 !== "number" || typeof x2 !== "number" || typeof y2 !== "number") {
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
370
386
|
|
|
371
|
-
|
|
372
|
-
|
|
387
|
+
const cross = x1 * y2 - x2 * y1
|
|
388
|
+
ringArea += cross
|
|
389
|
+
ringLon += (x1 + x2) * cross
|
|
390
|
+
ringLat += (y1 + y2) * cross
|
|
391
|
+
// The vertex-average fallback accumulates alongside — one pass, both answers.
|
|
392
|
+
sumLon += x1
|
|
393
|
+
sumLat += y1
|
|
373
394
|
|
|
374
395
|
count++
|
|
375
396
|
}
|
|
397
|
+
|
|
398
|
+
ringArea /= 2
|
|
399
|
+
|
|
400
|
+
if (ringArea === 0) continue
|
|
401
|
+
|
|
402
|
+
const weight = Math.abs(ringArea)
|
|
403
|
+
totalArea += weight
|
|
404
|
+
weightedLon += (ringLon / (6 * ringArea)) * weight
|
|
405
|
+
weightedLat += (ringLat / (6 * ringArea)) * weight
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (totalArea > 0) {
|
|
409
|
+
return { lat: weightedLat / totalArea, lon: weightedLon / totalArea }
|
|
376
410
|
}
|
|
377
411
|
|
|
378
412
|
if (count === 0) return undefined
|
|
@@ -382,16 +416,17 @@ export function geometryCentroid(geometryJSON: string | null): { lat: number; lo
|
|
|
382
416
|
|
|
383
417
|
/**
|
|
384
418
|
* The production `blockCentroids` supplier: opens the TIGER blocks database READ-ONLY and probes `tabblock20.GEOID`
|
|
385
|
-
* (uppercase) per lookup, decoding its GeoJSON `geometry` column via {@linkcode geometryCentroid}.
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
* resolver-wof-sqlite
|
|
419
|
+
* (uppercase) per lookup, decoding its GeoJSON `geometry` column via {@linkcode geometryCentroid}. The factory awaits
|
|
420
|
+
* its read-only open; the per-lookup probe and the `BuildBDCOptions.blockCentroids` interface stay synchronous — a
|
|
421
|
+
* plain sync function (the same sync-by-interface discipline AGENTS.md documents for the resolver ladder), so the
|
|
422
|
+
* returned closure uses `node:sqlite`'s raw `.prepare()`/`.get()` directly rather than Kysely. The connection is left
|
|
423
|
+
* open for the caller's process lifetime (a read-path lookup, not a build) — same lifecycle as the resolver-wof-sqlite
|
|
424
|
+
* lookups.
|
|
390
425
|
*/
|
|
391
|
-
export function createTIGERBlockCentroidLookup(
|
|
426
|
+
export async function createTIGERBlockCentroidLookup(
|
|
392
427
|
tigerDBPath: string
|
|
393
|
-
): (geoid: string) => { lat: number; lon: number } | undefined {
|
|
394
|
-
const db =
|
|
428
|
+
): Promise<(geoid: string) => { lat: number; lon: number } | undefined> {
|
|
429
|
+
const db = await openBuiltClient(tigerDBPath)
|
|
395
430
|
const stmt = db.prepare("SELECT geometry FROM tabblock20 WHERE GEOID = ?")
|
|
396
431
|
|
|
397
432
|
return (geoid: string) => {
|
|
@@ -454,15 +489,18 @@ async function groupProviderListRows(
|
|
|
454
489
|
* `brand_name` is always inserted NULL — the provider list carries no brand-name column at all, so there is nothing to
|
|
455
490
|
* populate it from, primary or otherwise (see the schema docstring).
|
|
456
491
|
*
|
|
457
|
-
* **Lazy `@mailwoman/filer/
|
|
458
|
-
* import("@mailwoman/filer/
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
492
|
+
* **Lazy `@mailwoman/filer/filer-lookup` import.** `readFRNFilingCandidates`/`pickPrimaryFRN` are loaded via `await
|
|
493
|
+
* import("@mailwoman/filer/filer-lookup")`, memoized in `filerSDK` below, rather than a top-level static import. The
|
|
494
|
+
* cost this avoids is smaller than it was: the specifier used to be the `@mailwoman/filer/sdk` BARREL, which `export
|
|
495
|
+
* *`s `cluster-filers.ts` and so pulls `@mailwoman/match`/`record`/`registry` in behind it — a top-level import of that
|
|
496
|
+
* barrel regressed `@mailwoman/bdc`'s import time ~32% for EVERY consumer, including ones that never populate
|
|
497
|
+
* providers. `filer-lookup.ts` alone imports only `@mailwoman/sqlite/client`, `#schema` and `#frn` (measured
|
|
498
|
+
* 2026-09-01), so the heavy graph is no longer on this path at all. The laziness is kept because it also defers opening
|
|
499
|
+
* the filer database, and a static import here is now a viable simplification if someone wants to measure it — but it
|
|
500
|
+
* is no longer load-bearing for import time.
|
|
463
501
|
*/
|
|
464
502
|
async function populateBDCProviderTable(
|
|
465
|
-
|
|
503
|
+
db: DatabaseClient<BDCDatabase>,
|
|
466
504
|
providers: Iterable<ProviderListRow> | AsyncIterable<ProviderListRow>,
|
|
467
505
|
filerDB: DatabaseClient<FilerDatabase> | undefined,
|
|
468
506
|
asOf: string
|
|
@@ -470,7 +508,7 @@ async function populateBDCProviderTable(
|
|
|
470
508
|
const byProviderID = await groupProviderListRows(providers)
|
|
471
509
|
const insertRows: Insertable<BDCProviderTable>[] = []
|
|
472
510
|
|
|
473
|
-
let filerSDK: typeof import("@mailwoman/filer/
|
|
511
|
+
let filerSDK: typeof import("@mailwoman/filer/filer-lookup") | undefined
|
|
474
512
|
|
|
475
513
|
for (const [providerID, rows] of byProviderID) {
|
|
476
514
|
const distinctFRNs = [...new Set(rows.map((row) => row.frn))]
|
|
@@ -488,7 +526,7 @@ async function populateBDCProviderTable(
|
|
|
488
526
|
)
|
|
489
527
|
}
|
|
490
528
|
|
|
491
|
-
filerSDK ??= await import("@mailwoman/filer/
|
|
529
|
+
filerSDK ??= await import("@mailwoman/filer/filer-lookup")
|
|
492
530
|
|
|
493
531
|
const candidates = await filerSDK.readFRNFilingCandidates(filerDB, distinctFRNs, asOf)
|
|
494
532
|
|
|
@@ -505,7 +543,7 @@ async function populateBDCProviderTable(
|
|
|
505
543
|
}
|
|
506
544
|
|
|
507
545
|
for (let index = 0; index < insertRows.length; index += PROVIDER_INSERT_BATCH_SIZE) {
|
|
508
|
-
await
|
|
546
|
+
await db
|
|
509
547
|
.insertInto("bdc_provider")
|
|
510
548
|
.values(insertRows.slice(index, index + PROVIDER_INSERT_BATCH_SIZE))
|
|
511
549
|
.execute()
|
|
@@ -530,278 +568,285 @@ export async function buildBDCDatabase(options: BuildBDCOptions): Promise<BuildB
|
|
|
530
568
|
|
|
531
569
|
const buildingPath = `${options.out}.building`
|
|
532
570
|
|
|
533
|
-
if (
|
|
534
|
-
|
|
571
|
+
if (await pathExists(buildingPath)) {
|
|
572
|
+
await removePath(buildingPath)
|
|
535
573
|
}
|
|
536
574
|
|
|
537
|
-
|
|
575
|
+
await makeDirectories(dirname(options.out))
|
|
576
|
+
|
|
577
|
+
// A crash inside a PRIOR run's swap can leave the slot empty while the previous version sits
|
|
578
|
+
// parked aside — restore it before building, so a failure in THIS run still leaves an artifact
|
|
579
|
+
// serving. Both aside spellings: this builder's old `.prev` and swapDatabaseIntoPlace's `.old-<pid>`.
|
|
580
|
+
if (!(await pathExists(options.out))) {
|
|
581
|
+
const base = basename(options.out)
|
|
582
|
+
|
|
583
|
+
const parked = (await readDirectory(dirname(options.out))).find(
|
|
584
|
+
(name) => name === `${base}.prev` || name.startsWith(`${base}.old-`)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
if (parked) {
|
|
588
|
+
await movePath(join(dirname(options.out), parked), options.out)
|
|
589
|
+
progress(`restored ${parked} into place (a prior run crashed mid-swap)`)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
538
592
|
|
|
539
593
|
const rowSource: AsyncIterable<BDCAvailabilityRow> | Iterable<BDCAvailabilityRow> =
|
|
540
594
|
options.rows ?? readAvailabilityRowsFromCSVPaths(options.csvPaths!)
|
|
541
595
|
|
|
542
|
-
const db = new
|
|
596
|
+
const db = new DatabaseClient<BDCDatabase>(buildingPath)
|
|
543
597
|
// Build-tuning pragmas — identical to build-poi.ts's discipline.
|
|
544
598
|
db.exec("PRAGMA page_size=8192; PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA cache_size=-2000000;")
|
|
545
|
-
const kdb = new DatabaseClient<BDCDatabase>({ database: db })
|
|
546
599
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
await createLayerCoverageTable(asContractDB(kdb))
|
|
550
|
-
await createBDCAvailabilityTable(kdb)
|
|
551
|
-
await createBDCProviderTable(kdb)
|
|
552
|
-
await createBDCStageTable(kdb)
|
|
600
|
+
// Assigned at the end of the try — the tallies live inside its scope; the seal + swap do not.
|
|
601
|
+
let result: BuildBDCResult
|
|
553
602
|
|
|
554
|
-
|
|
555
|
-
|
|
603
|
+
try {
|
|
604
|
+
progress("creating manifest/coverage/availability/provider/stage tables")
|
|
605
|
+
await createLayerManifestTable(db)
|
|
606
|
+
await createLayerCoverageTable(db)
|
|
607
|
+
await createBDCAvailabilityTable(db)
|
|
608
|
+
await createBDCProviderTable(db)
|
|
609
|
+
await createBDCStageTable(db)
|
|
610
|
+
|
|
611
|
+
const insStage = db.prepare(
|
|
612
|
+
`INSERT OR IGNORE INTO bdc_stage (
|
|
556
613
|
geoid, provider_id, technology_code, location_id,
|
|
557
614
|
max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code
|
|
558
615
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
559
|
-
)
|
|
560
|
-
|
|
561
|
-
let staged = 0
|
|
562
|
-
let batch = 0
|
|
563
|
-
|
|
564
|
-
progress("staging rows — raw prepared INSERT OR IGNORE on the natural key (the Redis-dedup replacement)")
|
|
565
|
-
db.exec("BEGIN")
|
|
566
|
-
|
|
567
|
-
for await (const row of rowSource) {
|
|
568
|
-
insStage.run(
|
|
569
|
-
row.geoid,
|
|
570
|
-
row.provider_id,
|
|
571
|
-
row.technology_code,
|
|
572
|
-
row.location_id,
|
|
573
|
-
row.max_advertised_download_speed,
|
|
574
|
-
row.max_advertised_upload_speed,
|
|
575
|
-
row.low_latency,
|
|
576
|
-
row.business_residential_code
|
|
577
616
|
)
|
|
578
617
|
|
|
579
|
-
staged
|
|
618
|
+
let staged = 0
|
|
580
619
|
|
|
581
|
-
|
|
620
|
+
progress("staging rows — raw prepared INSERT OR IGNORE on the natural key (the Redis-dedup replacement)")
|
|
582
621
|
|
|
583
|
-
|
|
584
|
-
db.exec("COMMIT")
|
|
585
|
-
db.exec("BEGIN")
|
|
586
|
-
batch = 0
|
|
587
|
-
}
|
|
588
|
-
}
|
|
622
|
+
const stageBatch = beginBatched(db, { rowsPerCommit: STAGE_BATCH_SIZE })
|
|
589
623
|
|
|
590
|
-
|
|
624
|
+
for await (const row of rowSource) {
|
|
625
|
+
insStage.run(
|
|
626
|
+
row.geoid,
|
|
627
|
+
row.provider_id,
|
|
628
|
+
row.technology_code,
|
|
629
|
+
row.location_id,
|
|
630
|
+
row.max_advertised_download_speed,
|
|
631
|
+
row.max_advertised_upload_speed,
|
|
632
|
+
row.low_latency,
|
|
633
|
+
row.business_residential_code
|
|
634
|
+
)
|
|
591
635
|
|
|
592
|
-
|
|
593
|
-
staged_count: number
|
|
594
|
-
}
|
|
636
|
+
staged++
|
|
595
637
|
|
|
596
|
-
|
|
638
|
+
stageBatch.rowWritten()
|
|
639
|
+
}
|
|
597
640
|
|
|
598
|
-
|
|
599
|
-
`staged ${stagedCountRow.staged_count.toLocaleString()} distinct row(s), ${deduped.toLocaleString()} deduped`
|
|
600
|
-
)
|
|
641
|
+
stageBatch.commit()
|
|
601
642
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
643
|
+
const stagedCountRow = db.prepare("SELECT COUNT(*) AS staged_count FROM bdc_stage").get() as {
|
|
644
|
+
staged_count: number
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const deduped = staged - stagedCountRow.staged_count
|
|
648
|
+
|
|
649
|
+
progress(
|
|
650
|
+
`staged ${stagedCountRow.staged_count.toLocaleString()} distinct row(s), ${deduped.toLocaleString()} deduped`
|
|
651
|
+
)
|
|
611
652
|
|
|
612
|
-
|
|
613
|
-
|
|
653
|
+
const centroidCache = new Map<string, { h3Cell: number; coverageCell: number } | null>()
|
|
654
|
+
/**
|
|
655
|
+
* Res-6 short-cell int → observed row count, aggregated during materialize (one pass, no second scan) — matches
|
|
656
|
+
* `build-poi.ts`'s `coverage` Map.
|
|
657
|
+
*/
|
|
658
|
+
const coverage = new Map<number, number>()
|
|
659
|
+
const providers = new Set<number>()
|
|
660
|
+
let unknownGeoids = 0
|
|
661
|
+
let inserted = 0
|
|
662
|
+
|
|
663
|
+
const insAvailability = db.prepare(
|
|
664
|
+
`INSERT INTO bdc_availability (
|
|
614
665
|
h3_cell, geoid, wof_id, provider_id, technology_code,
|
|
615
666
|
max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code, location_id
|
|
616
667
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
// The FCC's per-provider CSVs are per-BSL: the SAME (geoid, provider_id, technology_code, speeds, low_latency,
|
|
671
|
+
// business_residential_code) tuple can repeat once per Broadband Serviceable Location within that block (a
|
|
672
|
+
// dense urban block can carry ~100 BSLs) — `bdc_stage`'s natural key includes `location_id`, so those BSL rows
|
|
673
|
+
// all survive the staging dedup as distinct staged rows. In `includeLocationIDs` mode that's correct: every BSL
|
|
674
|
+
// is a real, distinct row the caller asked to keep. In the default (NULL `location_id`) mode, when those BSLs
|
|
675
|
+
// ALSO share identical speeds/flags, they'd otherwise materialize as byte-identical rows, inflating `result.rows`
|
|
676
|
+
// and `layer_coverage.observed_rows` by the BSL count (~100x at real scale) — `SELECT DISTINCT`
|
|
677
|
+
// over every column EXCEPT `location_id` collapses those byte-identical BSL duplicates down to one row.
|
|
678
|
+
// IMPORTANT — this is NOT a guarantee of one row per (geoid, provider_id, technology_code) triple: BSLs at the
|
|
679
|
+
// same triple with DIFFERING speeds/flags are NOT the same tuple, so `SELECT DISTINCT` does not merge them —
|
|
680
|
+
// they survive as multiple NULL-`location_id` rows at that one triple. Accepted, not a bug; see the module
|
|
681
|
+
// docstring and `filing-landscape.ts`'s docstring for the read-side consequence.
|
|
682
|
+
const stageStmt = options.includeLocationIDs
|
|
683
|
+
? db.prepare(
|
|
684
|
+
`SELECT geoid, provider_id, technology_code, location_id,
|
|
634
685
|
max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code
|
|
635
686
|
FROM bdc_stage`
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
687
|
+
)
|
|
688
|
+
: db.prepare(
|
|
689
|
+
`SELECT DISTINCT geoid, provider_id, technology_code,
|
|
639
690
|
max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code
|
|
640
691
|
FROM bdc_stage`
|
|
641
|
-
|
|
692
|
+
)
|
|
642
693
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
db.exec("BEGIN")
|
|
648
|
-
batch = 0
|
|
649
|
-
|
|
650
|
-
for (const row of stageStmt.iterate() as IterableIterator<BDCStageRow>) {
|
|
651
|
-
let resolved = centroidCache.get(row.geoid)
|
|
652
|
-
|
|
653
|
-
if (resolved === undefined) {
|
|
654
|
-
const centroid = options.blockCentroids(row.geoid)
|
|
655
|
-
|
|
656
|
-
resolved = centroid
|
|
657
|
-
? (() => {
|
|
658
|
-
// Coverage cell MUST be derived as the res-9 cell's H3 hierarchy parent — NOT a second,
|
|
659
|
-
// independent `latLngToCell(centroid, 6)` call. H3's cell hierarchy is not geometrically
|
|
660
|
-
// exact: a point's directly-indexed res-6 cell and its res-9 cell's `cellToParent(…, 6)`
|
|
661
|
-
// disagree for a real fraction of points (~6% empirically over CONUS — hexagon/pentagon
|
|
662
|
-
// boundary artifacts). Deriving both `h3_cell` and the coverage cell from
|
|
663
|
-
// the SAME full res-9 index is what lets `filing-landscape.ts`'s reader reconstruct this
|
|
664
|
-
// exact coverage cell from nothing but the stored `h3_cell` (its `res9ShortCellToRes6Parent`
|
|
665
|
-
// applies `cellToParent` to the reconstructed res-9 cell) — builder and reader must derive
|
|
666
|
-
// the res-6 parent identically, or a genuinely-surveyed block can read back as unknown.
|
|
667
|
-
const fullRes9Cell = latLngToCell(centroid.lat, centroid.lon, BDC_H3_RESOLUTION) as H3Cell
|
|
668
|
-
|
|
669
|
-
return {
|
|
670
|
-
h3Cell: shortCellToInt(fullRes9Cell),
|
|
671
|
-
coverageCell: shortCellToInt(cellToParent(fullRes9Cell, BDC_COVERAGE_H3_RESOLUTION) as H3Cell),
|
|
672
|
-
}
|
|
673
|
-
})()
|
|
674
|
-
: null
|
|
675
|
-
|
|
676
|
-
centroidCache.set(row.geoid, resolved)
|
|
677
|
-
}
|
|
694
|
+
progress(
|
|
695
|
+
"materializing bdc_availability — resolving block centroids to h3_cell (unknown geoids skipped, never guessed)"
|
|
696
|
+
)
|
|
678
697
|
|
|
679
|
-
|
|
680
|
-
|
|
698
|
+
const materializeBatch = beginBatched(db, { rowsPerCommit: STAGE_BATCH_SIZE })
|
|
699
|
+
|
|
700
|
+
for (const row of stageStmt.iterate() as IterableIterator<BDCStageRow>) {
|
|
701
|
+
let resolved = centroidCache.get(row.geoid)
|
|
702
|
+
|
|
703
|
+
if (resolved === undefined) {
|
|
704
|
+
const centroid = options.blockCentroids(row.geoid)
|
|
705
|
+
|
|
706
|
+
resolved = centroid
|
|
707
|
+
? (() => {
|
|
708
|
+
// Coverage cell MUST be derived as the res-9 cell's H3 hierarchy parent — NOT a second,
|
|
709
|
+
// independent `latLngToCell(centroid, 6)` call. H3's cell hierarchy is not geometrically
|
|
710
|
+
// exact: a point's directly-indexed res-6 cell and its res-9 cell's `cellToParent(…, 6)`
|
|
711
|
+
// disagree for a real fraction of points (~6% empirically over CONUS — hexagon/pentagon
|
|
712
|
+
// boundary artifacts). Deriving both `h3_cell` and the coverage cell from
|
|
713
|
+
// the SAME full res-9 index is what lets `filing-landscape.ts`'s reader reconstruct this
|
|
714
|
+
// exact coverage cell from nothing but the stored `h3_cell` (its `res9ShortCellToRes6Parent`
|
|
715
|
+
// applies `cellToParent` to the reconstructed res-9 cell) — builder and reader must derive
|
|
716
|
+
// the res-6 parent identically, or a genuinely-surveyed block can read back as unknown.
|
|
717
|
+
const fullRes9Cell = latLngToCell(centroid.lat, centroid.lon, BDC_H3_RESOLUTION) as H3Cell
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
h3Cell: shortCellToInt(fullRes9Cell),
|
|
721
|
+
coverageCell: shortCellToInt(cellToParent(fullRes9Cell, BDC_COVERAGE_H3_RESOLUTION) as H3Cell),
|
|
722
|
+
}
|
|
723
|
+
})()
|
|
724
|
+
: null
|
|
725
|
+
|
|
726
|
+
centroidCache.set(row.geoid, resolved)
|
|
727
|
+
}
|
|
681
728
|
|
|
682
|
-
|
|
683
|
-
|
|
729
|
+
if (!resolved) {
|
|
730
|
+
unknownGeoids++
|
|
684
731
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
row.geoid,
|
|
688
|
-
// wof_id stays NULL here — WOF point-in-polygon resolution against the block centroid is a later
|
|
689
|
-
// registry-join task, the same decision-8 scoping schema.ts documents for `bdc_provider`.
|
|
690
|
-
null,
|
|
691
|
-
row.provider_id,
|
|
692
|
-
row.technology_code,
|
|
693
|
-
row.max_advertised_download_speed,
|
|
694
|
-
row.max_advertised_upload_speed,
|
|
695
|
-
row.low_latency,
|
|
696
|
-
row.business_residential_code,
|
|
697
|
-
options.includeLocationIDs ? (row.location_id ?? null) : null
|
|
698
|
-
)
|
|
732
|
+
continue
|
|
733
|
+
}
|
|
699
734
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
735
|
+
insAvailability.run(
|
|
736
|
+
resolved.h3Cell,
|
|
737
|
+
row.geoid,
|
|
738
|
+
// wof_id stays NULL here — WOF point-in-polygon resolution against the block centroid is a later
|
|
739
|
+
// registry-join task, the same decision-8 scoping schema.ts documents for `bdc_provider`.
|
|
740
|
+
null,
|
|
741
|
+
row.provider_id,
|
|
742
|
+
row.technology_code,
|
|
743
|
+
row.max_advertised_download_speed,
|
|
744
|
+
row.max_advertised_upload_speed,
|
|
745
|
+
row.low_latency,
|
|
746
|
+
row.business_residential_code,
|
|
747
|
+
options.includeLocationIDs ? (row.location_id ?? null) : null
|
|
748
|
+
)
|
|
703
749
|
|
|
704
|
-
|
|
750
|
+
inserted++
|
|
751
|
+
providers.add(row.provider_id)
|
|
752
|
+
coverage.set(resolved.coverageCell, (coverage.get(resolved.coverageCell) ?? 0) + 1)
|
|
705
753
|
|
|
706
|
-
|
|
707
|
-
db.exec("COMMIT")
|
|
708
|
-
db.exec("BEGIN")
|
|
709
|
-
batch = 0
|
|
754
|
+
materializeBatch.rowWritten()
|
|
710
755
|
}
|
|
711
|
-
}
|
|
712
756
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
)
|
|
719
|
-
|
|
720
|
-
await kdb.schema.dropTable("bdc_stage").execute()
|
|
721
|
-
|
|
722
|
-
progress("geoid index (index-after-load — see schema.ts)")
|
|
723
|
-
await createBDCGeoidIndex(kdb)
|
|
724
|
-
|
|
725
|
-
// Coverage is SOURCE-LEVEL, not survey completeness — same convention build-poi.ts documents: a res-6 cell we
|
|
726
|
-
// have availability rows in is recorded at completeness 1.0. A cell absent from `layer_coverage` means no rows
|
|
727
|
-
// were observed there at all (the meaning-of-zero rule — missing = unknown, never `{completeness: 0}`).
|
|
728
|
-
const coverageCells = [...coverage.entries()].map(([h3Cell, observedRows]) => ({
|
|
729
|
-
h3Cell,
|
|
730
|
-
completeness: 1,
|
|
731
|
-
observedRows,
|
|
732
|
-
}))
|
|
733
|
-
|
|
734
|
-
await writeLayerCoverage(asContractDB(kdb), coverageCells)
|
|
735
|
-
|
|
736
|
-
progress("writing layer manifest")
|
|
737
|
-
|
|
738
|
-
await writeLayerManifest(asContractDB(kdb), {
|
|
739
|
-
name: "bdc",
|
|
740
|
-
version: options.asOfDate,
|
|
741
|
-
schemaVersion: 1,
|
|
742
|
-
tier: LayerTier.Shipped,
|
|
743
|
-
license: "public-domain",
|
|
744
|
-
attribution: BDC_ATTRIBUTION,
|
|
745
|
-
source: "fcc-bdc",
|
|
746
|
-
sourceVintage: options.asOfDate,
|
|
747
|
-
buildCmd: "mailwoman gazetteer build bdc",
|
|
748
|
-
buildSHA: options.buildSHA,
|
|
749
|
-
freshnessPolicy: LayerFreshnessPolicy.VersionedRefresh,
|
|
750
|
-
spineKeys: { h3: { column: "h3_cell", resolution: BDC_H3_RESOLUTION }, wofID: "wof_id" },
|
|
751
|
-
createdAt: new Date().toISOString(),
|
|
752
|
-
})
|
|
753
|
-
|
|
754
|
-
// bdc_provider population (2a decision 8 / 3a decision 6) — entirely additive and gated behind
|
|
755
|
-
// `options.providers`: when absent, this block never runs and `bdc_provider` stays empty (see
|
|
756
|
-
// `BuildBDCOptions.providers`'s docstring for the default-path guarantee).
|
|
757
|
-
let providersPopulated = 0
|
|
758
|
-
|
|
759
|
-
if (options.providers) {
|
|
760
|
-
progress("populating bdc_provider from the provider list (decision 6 — lossy denormalization, see schema.ts)")
|
|
761
|
-
|
|
762
|
-
providersPopulated = await populateBDCProviderTable(
|
|
763
|
-
kdb,
|
|
764
|
-
options.providers,
|
|
765
|
-
options.filerDB,
|
|
766
|
-
options.primaryFRNAsOf ?? options.asOfDate
|
|
757
|
+
materializeBatch.commit()
|
|
758
|
+
|
|
759
|
+
progress(
|
|
760
|
+
`materialized ${inserted.toLocaleString()} row(s) across ${providers.size} provider(s) ` +
|
|
761
|
+
`(${unknownGeoids.toLocaleString()} unknown geoid(s) skipped)`
|
|
767
762
|
)
|
|
768
763
|
|
|
769
|
-
|
|
770
|
-
|
|
764
|
+
await db.schema.dropTable("bdc_stage").execute()
|
|
765
|
+
|
|
766
|
+
progress("geoid index (index-after-load — see schema.ts)")
|
|
767
|
+
await createBDCGeoidIndex(db)
|
|
768
|
+
|
|
769
|
+
// Coverage is SOURCE-LEVEL, not survey completeness — same convention build-poi.ts documents: a res-6 cell we
|
|
770
|
+
// have availability rows in is recorded at completeness 1.0. A cell absent from `layer_coverage` means no rows
|
|
771
|
+
// were observed there at all (the meaning-of-zero rule — missing = unknown, never `{completeness: 0}`).
|
|
772
|
+
const coverageCells = sourcePresentCoverageCells(coverage)
|
|
773
|
+
|
|
774
|
+
await writeLayerCoverage(db, coverageCells)
|
|
775
|
+
|
|
776
|
+
progress("writing layer manifest")
|
|
777
|
+
|
|
778
|
+
await writeLayerManifest(db, {
|
|
779
|
+
name: "bdc",
|
|
780
|
+
version: options.asOfDate,
|
|
781
|
+
schemaVersion: 1,
|
|
782
|
+
tier: LayerTier.Shipped,
|
|
783
|
+
license: "LicenseRef-USGov-Public-Domain",
|
|
784
|
+
attribution: BDC_ATTRIBUTION,
|
|
785
|
+
source: "fcc-bdc",
|
|
786
|
+
sourceVintage: options.asOfDate,
|
|
787
|
+
buildCmd: "mailwoman gazetteer build bdc",
|
|
788
|
+
buildSHA: options.buildSHA,
|
|
789
|
+
freshnessPolicy: LayerFreshnessPolicy.VersionedRefresh,
|
|
790
|
+
spineKeys: { h3: { column: "h3_cell", resolution: BDC_H3_RESOLUTION }, wofID: "wof_id" },
|
|
791
|
+
createdAt: new Date().toISOString(),
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
// bdc_provider population (2a decision 8 / 3a decision 6) — entirely additive and conditioned on
|
|
795
|
+
// `options.providers`: when absent, this block never runs and `bdc_provider` stays empty (see
|
|
796
|
+
// `BuildBDCOptions.providers`'s docstring for the default-path guarantee).
|
|
797
|
+
let providersPopulated = 0
|
|
798
|
+
|
|
799
|
+
if (options.providers) {
|
|
800
|
+
progress("populating bdc_provider from the provider list (decision 6 — lossy denormalization, see schema.ts)")
|
|
801
|
+
|
|
802
|
+
providersPopulated = await populateBDCProviderTable(
|
|
803
|
+
db,
|
|
804
|
+
options.providers,
|
|
805
|
+
options.filerDB,
|
|
806
|
+
options.primaryFRNAsOf ?? options.asOfDate
|
|
807
|
+
)
|
|
771
808
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
// page_size MUST be set right before VACUUM — node:sqlite initializes the file at the 4096 default on
|
|
775
|
-
// `new DatabaseSync`, so the earlier pragma is a no-op until a VACUUM rebuilds at the new size (build-poi.ts's
|
|
776
|
-
// same discipline).
|
|
777
|
-
db.exec("PRAGMA page_size=8192")
|
|
778
|
-
db.exec("VACUUM")
|
|
779
|
-
await kdb.destroy()
|
|
809
|
+
progress(`bdc_provider: ${providersPopulated.toLocaleString()} provider(s) populated`)
|
|
810
|
+
}
|
|
780
811
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
812
|
+
progress("finalize: ANALYZE + VACUUM")
|
|
813
|
+
db.exec("ANALYZE")
|
|
814
|
+
// page_size MUST be set right before VACUUM — node:sqlite initializes the file at the 4096 default on
|
|
815
|
+
// `new DatabaseSync`, so the earlier pragma is a no-op until a VACUUM rebuilds at the new size (build-poi.ts's
|
|
816
|
+
// same discipline).
|
|
817
|
+
db.exec("PRAGMA page_size=8192")
|
|
818
|
+
db.exec("VACUUM")
|
|
819
|
+
await db.destroy()
|
|
820
|
+
|
|
821
|
+
result = {
|
|
822
|
+
out: options.out,
|
|
823
|
+
rows: inserted,
|
|
824
|
+
deduped,
|
|
825
|
+
providers: providers.size,
|
|
826
|
+
coverageCells: coverageCells.length,
|
|
827
|
+
unknownGeoids,
|
|
828
|
+
providersPopulated,
|
|
829
|
+
}
|
|
830
|
+
} catch (error) {
|
|
831
|
+
// A mid-build throw must not leak the handle or orphan the staging file. The original error
|
|
832
|
+
// always wins over anything the cleanup itself throws.
|
|
833
|
+
try {
|
|
834
|
+
await db.destroy()
|
|
835
|
+
} catch {
|
|
836
|
+
// The handle may already be closed or mid-statement — nothing more to release.
|
|
837
|
+
}
|
|
791
838
|
|
|
792
|
-
|
|
839
|
+
await removePathIfPresent(buildingPath)
|
|
793
840
|
|
|
794
|
-
|
|
795
|
-
rmSync(`${options.out}.prev`)
|
|
841
|
+
throw error
|
|
796
842
|
}
|
|
797
843
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
844
|
+
progress("seal")
|
|
845
|
+
await sealDatabase(buildingPath)
|
|
846
|
+
|
|
847
|
+
// Atomic move-into-place via the shared helper (the AGENTS.md database house rule): prior
|
|
848
|
+
// version aside first, forward rename restored on failure so the slot is never left empty.
|
|
849
|
+
await swapDatabaseIntoPlace(buildingPath, options.out)
|
|
850
|
+
|
|
851
|
+
return result
|
|
807
852
|
}
|