@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.
Files changed (84) hide show
  1. package/lib/env.ts +30 -0
  2. package/lib/index.ts +2 -0
  3. package/{sdk → lib/sdk}/build-bdc.ts +343 -298
  4. package/{sdk → lib/sdk}/client.ts +24 -38
  5. package/{sdk → lib/sdk}/common.ts +1 -1
  6. package/{sdk → lib/sdk}/data-collection.ts +2 -2
  7. package/{sdk → lib/sdk}/download.ts +19 -15
  8. package/{sdk → lib/sdk}/filing-dates.ts +3 -3
  9. package/{sdk → lib/sdk}/filing-landscape.ts +12 -24
  10. package/lib/sdk/index.ts +14 -0
  11. package/{sdk → lib/sdk}/list-files.ts +2 -2
  12. package/{sdk → lib/sdk}/nearest-infrastructure.ts +5 -5
  13. package/lib/sdk/parsing.ts +157 -0
  14. package/{sdk → lib/sdk}/plausibility.ts +43 -29
  15. package/out/env.d.ts +24 -0
  16. package/out/env.d.ts.map +1 -0
  17. package/out/env.js +28 -0
  18. package/out/env.js.map +1 -0
  19. package/out/index.d.ts +2 -2
  20. package/out/index.d.ts.map +1 -1
  21. package/out/index.js +2 -2
  22. package/out/index.js.map +1 -1
  23. package/out/schema.d.ts.map +1 -1
  24. package/out/schema.js.map +1 -1
  25. package/out/sdk/build-bdc.d.ts +32 -25
  26. package/out/sdk/build-bdc.d.ts.map +1 -1
  27. package/out/sdk/build-bdc.js +255 -219
  28. package/out/sdk/build-bdc.js.map +1 -1
  29. package/out/sdk/buildings.d.ts.map +1 -1
  30. package/out/sdk/buildings.js.map +1 -1
  31. package/out/sdk/client.d.ts +7 -7
  32. package/out/sdk/client.d.ts.map +1 -1
  33. package/out/sdk/client.js +17 -29
  34. package/out/sdk/client.js.map +1 -1
  35. package/out/sdk/common.d.ts +1 -1
  36. package/out/sdk/common.d.ts.map +1 -1
  37. package/out/sdk/common.js.map +1 -1
  38. package/out/sdk/data-collection.d.ts +2 -2
  39. package/out/sdk/data-collection.d.ts.map +1 -1
  40. package/out/sdk/data-collection.js.map +1 -1
  41. package/out/sdk/download.d.ts +4 -3
  42. package/out/sdk/download.d.ts.map +1 -1
  43. package/out/sdk/download.js +12 -13
  44. package/out/sdk/download.js.map +1 -1
  45. package/out/sdk/filing-dates.d.ts +3 -3
  46. package/out/sdk/filing-dates.d.ts.map +1 -1
  47. package/out/sdk/filing-dates.js +1 -1
  48. package/out/sdk/filing-dates.js.map +1 -1
  49. package/out/sdk/filing-landscape.d.ts +4 -4
  50. package/out/sdk/filing-landscape.d.ts.map +1 -1
  51. package/out/sdk/filing-landscape.js +9 -19
  52. package/out/sdk/filing-landscape.js.map +1 -1
  53. package/out/sdk/index.d.ts +14 -14
  54. package/out/sdk/index.d.ts.map +1 -1
  55. package/out/sdk/index.js +14 -14
  56. package/out/sdk/index.js.map +1 -1
  57. package/out/sdk/list-files.d.ts +2 -2
  58. package/out/sdk/list-files.d.ts.map +1 -1
  59. package/out/sdk/list-files.js +1 -1
  60. package/out/sdk/list-files.js.map +1 -1
  61. package/out/sdk/location.d.ts.map +1 -1
  62. package/out/sdk/location.js.map +1 -1
  63. package/out/sdk/nearest-infrastructure.d.ts +4 -4
  64. package/out/sdk/nearest-infrastructure.d.ts.map +1 -1
  65. package/out/sdk/nearest-infrastructure.js +2 -2
  66. package/out/sdk/nearest-infrastructure.js.map +1 -1
  67. package/out/sdk/parsing.d.ts +66 -28
  68. package/out/sdk/parsing.d.ts.map +1 -1
  69. package/out/sdk/parsing.js +103 -70
  70. package/out/sdk/parsing.js.map +1 -1
  71. package/out/sdk/plausibility.d.ts +32 -14
  72. package/out/sdk/plausibility.d.ts.map +1 -1
  73. package/out/sdk/plausibility.js +15 -20
  74. package/out/sdk/plausibility.js.map +1 -1
  75. package/out/sdk/technologies.d.ts.map +1 -1
  76. package/out/sdk/technologies.js.map +1 -1
  77. package/package.json +164 -22
  78. package/index.ts +0 -2
  79. package/sdk/index.ts +0 -14
  80. package/sdk/parsing.ts +0 -124
  81. /package/{schema.ts → lib/schema.ts} +0 -0
  82. /package/{sdk → lib/sdk}/buildings.ts +0 -0
  83. /package/{sdk → lib/sdk}/location.ts +0 -0
  84. /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 {@link asContractDB} Kysely-invariance cast for the shared
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 { existsSync, mkdirSync, renameSync, rmSync } from "node:fs"
55
- import { readFile } from "node:fs/promises"
56
- import { dirname } from "node:path"
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/sdk")`
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
- //. Only the TYPES are imported here; `import type` is fully erased, so
75
- // this line has zero runtime cost for every `@mailwoman/bdc` consumer that never populates providers.
76
- import type { FRN, ProviderListRow } from "@mailwoman/filer/sdk"
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 "../schema.ts"
90
- import type { ProviderID } from "./common.ts"
91
- import { takeAvailabilityLine, type BDCAvailabilityRow } from "./parsing.ts"
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 test seam (mirrors `BuildPOIOptions.rows`). When given, `csvPaths` is ignored and no
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 load-bearing: the materialize pass counts it in
143
- * `unknownGeoids` and skips the row — it must NEVER guess a cell.
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 seam for
149
- * populating `bdc_provider` (2a decision 8 / 3a decision 6). When ABSENT (the default), `bdc_provider` stays empty
150
- * and the rest of the build is untouched: every code path this option touches is gated behind `if
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 load-bearing, not defensive dressing: `bdc_stage.provider_id` is `INTEGER NOT NULL`, and a bare
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
- * Reads each of `csvPaths` fully into memory, peeks its `provider_id` ({@linkcode peekProviderID}, passing the path
312
- * through so a malformed file's error names it), then yields every row via `takeAvailabilityLine`. This is the
313
- * production counterpart to the test seam's injected `rows` exercised by `build-bdc.test.ts` only for the
314
- * malformed-provider-id rejection path, same as `build-poi.ts`'s `readParquetRows`.
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 buffer = await readFile(csvPath)
319
- const providerID = peekProviderID(buffer, csvPath)
318
+ const providerID = peekProviderID(await readFileRange(csvPath, 0, PROVIDER_ID_PEEK_BYTES), csvPath)
320
319
 
321
- yield* takeAvailabilityLine(buffer, providerID)
320
+ yield* readAvailabilityRows(csvPath, providerID)
322
321
  }
323
322
  }
324
323
 
@@ -336,11 +335,18 @@ interface GeoJSONMultiPolygon {
336
335
  }
337
336
 
338
337
  /**
339
- * Naive (vertex-average, NOT area-weighted) centroid of a GeoJSON `Polygon`/`MultiPolygon`'s EXTERIOR ring(s) only
340
- * (interior rings/holes are ignored). A known simplification, not an oversight: census blocks are small relative to a
341
- * res-9 H3 cell (~174m edge), so the vertex-average and a proper area-weighted centroid land in the same cell for all
342
- * but pathologically elongated or holed block shapes. A precise area-weighted centroid is a reasonable future upgrade
343
- * if that ever proves wrong in practice no polygon-centroid library is pulled in for this first cut.
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
- for (const point of ring) {
367
- const [lon, lat] = point
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 lon !== "number" || typeof lat !== "number") continue
383
+ if (typeof x1 !== "number" || typeof y1 !== "number" || typeof x2 !== "number" || typeof y2 !== "number") {
384
+ continue
385
+ }
370
386
 
371
- sumLon += lon
372
- sumLat += lat
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}. Kept synchronous
386
- * `BuildBDCOptions.blockCentroids` is a plain sync function (the same sync-by-interface discipline AGENTS.md documents
387
- * for the resolver ladder), so this uses `node:sqlite`'s raw `.prepare()`/`.get()` directly rather than Kysely. The
388
- * connection is left open for the caller's process lifetime (a read-path lookup, not a build) same lifecycle as the
389
- * resolver-wof-sqlite lookups.
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 = openBuiltDatabase(tigerDBPath)
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/sdk` import.** `readFRNFilingCandidates`/`pickPrimaryFRN` are loaded via `await
458
- * import("@mailwoman/filer/sdk")`, memoized in `filerSDK` below, rather than a top-level static import — that barrel
459
- * re-exports `cluster-filers.ts`, which pulls in `@mailwoman/match`/`record`/`registry`. A top-level import regressed
460
- * `@mailwoman/bdc`'s import time ~32% for EVERY consumer, including ones that never populate providers at all; the
461
- * dynamic import here only ever runs when a multi-FRN `provider_id` is actually encountered, so a `providers`-less
462
- * build (or one whose providers are all single-FRN) pays nothing.
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
- kdb: DatabaseClient<BDCDatabase>,
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/sdk") | undefined
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/sdk")
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 kdb
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 (existsSync(buildingPath)) {
534
- rmSync(buildingPath)
571
+ if (await pathExists(buildingPath)) {
572
+ await removePath(buildingPath)
535
573
  }
536
574
 
537
- mkdirSync(dirname(options.out), { recursive: true })
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 DatabaseSync(buildingPath)
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
- progress("creating manifest/coverage/availability/provider/stage tables")
548
- await createLayerManifestTable(asContractDB(kdb))
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
- const insStage = db.prepare(
555
- `INSERT OR IGNORE INTO bdc_stage (
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
- batch++
620
+ progress("staging rows — raw prepared INSERT OR IGNORE on the natural key (the Redis-dedup replacement)")
582
621
 
583
- if (batch >= STAGE_BATCH_SIZE) {
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
- db.exec("COMMIT")
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
- const stagedCountRow = db.prepare("SELECT COUNT(*) AS staged_count FROM bdc_stage").get() as {
593
- staged_count: number
594
- }
636
+ staged++
595
637
 
596
- const deduped = staged - stagedCountRow.staged_count
638
+ stageBatch.rowWritten()
639
+ }
597
640
 
598
- progress(
599
- `staged ${stagedCountRow.staged_count.toLocaleString()} distinct row(s), ${deduped.toLocaleString()} deduped`
600
- )
641
+ stageBatch.commit()
601
642
 
602
- const centroidCache = new Map<string, { h3Cell: number; coverageCell: number } | null>()
603
- /**
604
- * Res-6 short-cell int → observed row count, aggregated during materialize (one pass, no second scan) — matches
605
- * `build-poi.ts`'s `coverage` Map.
606
- */
607
- const coverage = new Map<number, number>()
608
- const providers = new Set<number>()
609
- let unknownGeoids = 0
610
- let inserted = 0
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
- const insAvailability = db.prepare(
613
- `INSERT INTO bdc_availability (
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
- // The FCC's per-provider CSVs are per-BSL: the SAME (geoid, provider_id, technology_code, speeds, low_latency,
620
- // business_residential_code) tuple can repeat once per Broadband Serviceable Location within that block (a
621
- // dense urban block can carry ~100 BSLs) — `bdc_stage`'s natural key includes `location_id`, so those BSL rows
622
- // all survive the staging dedup as distinct staged rows. In `includeLocationIDs` mode that's correct: every BSL
623
- // is a real, distinct row the caller asked to keep. In the default (NULL `location_id`) mode, when those BSLs
624
- // ALSO share identical speeds/flags, they'd otherwise materialize as byte-identical rows, inflating `result.rows`
625
- // and `layer_coverage.observed_rows` by the BSL count (~100x at real scale) — `SELECT DISTINCT`
626
- // over every column EXCEPT `location_id` collapses those byte-identical BSL duplicates down to one row.
627
- // IMPORTANT — this is NOT a guarantee of one row per (geoid, provider_id, technology_code) triple: BSLs at the
628
- // same triple with DIFFERING speeds/flags are NOT the same tuple, so `SELECT DISTINCT` does not merge them —
629
- // they survive as multiple NULL-`location_id` rows at that one triple. Accepted, not a bug; see the module
630
- // docstring and `filing-landscape.ts`'s docstring for the read-side consequence.
631
- const stageStmt = options.includeLocationIDs
632
- ? db.prepare(
633
- `SELECT geoid, provider_id, technology_code, location_id,
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
- : db.prepare(
638
- `SELECT DISTINCT geoid, provider_id, technology_code,
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
- progress(
644
- "materializing bdc_availability — resolving block centroids to h3_cell (unknown geoids skipped, never guessed)"
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
- if (!resolved) {
680
- unknownGeoids++
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
- continue
683
- }
729
+ if (!resolved) {
730
+ unknownGeoids++
684
731
 
685
- insAvailability.run(
686
- resolved.h3Cell,
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
- inserted++
701
- providers.add(row.provider_id)
702
- coverage.set(resolved.coverageCell, (coverage.get(resolved.coverageCell) ?? 0) + 1)
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
- batch++
750
+ inserted++
751
+ providers.add(row.provider_id)
752
+ coverage.set(resolved.coverageCell, (coverage.get(resolved.coverageCell) ?? 0) + 1)
705
753
 
706
- if (batch >= STAGE_BATCH_SIZE) {
707
- db.exec("COMMIT")
708
- db.exec("BEGIN")
709
- batch = 0
754
+ materializeBatch.rowWritten()
710
755
  }
711
- }
712
756
 
713
- db.exec("COMMIT")
714
-
715
- progress(
716
- `materialized ${inserted.toLocaleString()} row(s) across ${providers.size} provider(s) ` +
717
- `(${unknownGeoids.toLocaleString()} unknown geoid(s) skipped)`
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
- progress(`bdc_provider: ${providersPopulated.toLocaleString()} provider(s) populated`)
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
- progress("finalize: ANALYZE + VACUUM")
773
- db.exec("ANALYZE")
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
- progress("seal")
782
- sealDatabase(buildingPath)
783
-
784
- // Atomic move-into-place the previous version is moved ASIDE FIRST, per the AGENTS.md database house rule
785
- // ("build successfully, then move the previous version to a temp directory, and then move the new version into
786
- // place"). Mirrors `mailwoman/eval-harness/gauntlet/build-regression-db.ts`'s `${output}.prev` swap. Deliberate
787
- // deviation from `build-poi.ts`'s direct-write — see the module docstring.
788
- if (existsSync(options.out)) {
789
- renameSync(options.out, `${options.out}.prev`)
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
- renameSync(buildingPath, options.out)
839
+ await removePathIfPresent(buildingPath)
793
840
 
794
- if (existsSync(`${options.out}.prev`)) {
795
- rmSync(`${options.out}.prev`)
841
+ throw error
796
842
  }
797
843
 
798
- return {
799
- out: options.out,
800
- rows: inserted,
801
- deduped,
802
- providers: providers.size,
803
- coverageCells: coverageCells.length,
804
- unknownGeoids,
805
- providersPopulated,
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
  }