@mailwoman/bdc 8.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 (87) hide show
  1. package/README.md +7 -0
  2. package/index.ts +2 -0
  3. package/out/index.d.ts +3 -0
  4. package/out/index.d.ts.map +1 -0
  5. package/out/index.js +3 -0
  6. package/out/index.js.map +1 -0
  7. package/out/schema.d.ts +138 -0
  8. package/out/schema.d.ts.map +1 -0
  9. package/out/schema.js +88 -0
  10. package/out/schema.js.map +1 -0
  11. package/out/sdk/build-bdc.d.ts +214 -0
  12. package/out/sdk/build-bdc.d.ts.map +1 -0
  13. package/out/sdk/build-bdc.js +500 -0
  14. package/out/sdk/build-bdc.js.map +1 -0
  15. package/out/sdk/buildings.d.ts +84 -0
  16. package/out/sdk/buildings.d.ts.map +1 -0
  17. package/out/sdk/buildings.js +75 -0
  18. package/out/sdk/buildings.js.map +1 -0
  19. package/out/sdk/client.d.ts +237 -0
  20. package/out/sdk/client.d.ts.map +1 -0
  21. package/out/sdk/client.js +430 -0
  22. package/out/sdk/client.js.map +1 -0
  23. package/out/sdk/common.d.ts +279 -0
  24. package/out/sdk/common.d.ts.map +1 -0
  25. package/out/sdk/common.js +162 -0
  26. package/out/sdk/common.js.map +1 -0
  27. package/out/sdk/data-collection.d.ts +244 -0
  28. package/out/sdk/data-collection.d.ts.map +1 -0
  29. package/out/sdk/data-collection.js +50 -0
  30. package/out/sdk/data-collection.js.map +1 -0
  31. package/out/sdk/download.d.ts +36 -0
  32. package/out/sdk/download.d.ts.map +1 -0
  33. package/out/sdk/download.js +76 -0
  34. package/out/sdk/download.js.map +1 -0
  35. package/out/sdk/filing-dates.d.ts +57 -0
  36. package/out/sdk/filing-dates.d.ts.map +1 -0
  37. package/out/sdk/filing-dates.js +50 -0
  38. package/out/sdk/filing-dates.js.map +1 -0
  39. package/out/sdk/filing-landscape.d.ts +124 -0
  40. package/out/sdk/filing-landscape.d.ts.map +1 -0
  41. package/out/sdk/filing-landscape.js +216 -0
  42. package/out/sdk/filing-landscape.js.map +1 -0
  43. package/out/sdk/index.d.ts +15 -0
  44. package/out/sdk/index.d.ts.map +1 -0
  45. package/out/sdk/index.js +15 -0
  46. package/out/sdk/index.js.map +1 -0
  47. package/out/sdk/list-files.d.ts +39 -0
  48. package/out/sdk/list-files.d.ts.map +1 -0
  49. package/out/sdk/list-files.js +26 -0
  50. package/out/sdk/list-files.js.map +1 -0
  51. package/out/sdk/location.d.ts +40 -0
  52. package/out/sdk/location.d.ts.map +1 -0
  53. package/out/sdk/location.js +23 -0
  54. package/out/sdk/location.js.map +1 -0
  55. package/out/sdk/nearest-infrastructure.d.ts +84 -0
  56. package/out/sdk/nearest-infrastructure.d.ts.map +1 -0
  57. package/out/sdk/nearest-infrastructure.js +84 -0
  58. package/out/sdk/nearest-infrastructure.js.map +1 -0
  59. package/out/sdk/parsing.d.ts +57 -0
  60. package/out/sdk/parsing.d.ts.map +1 -0
  61. package/out/sdk/parsing.js +87 -0
  62. package/out/sdk/parsing.js.map +1 -0
  63. package/out/sdk/plausibility.d.ts +256 -0
  64. package/out/sdk/plausibility.d.ts.map +1 -0
  65. package/out/sdk/plausibility.js +334 -0
  66. package/out/sdk/plausibility.js.map +1 -0
  67. package/out/sdk/technologies.d.ts +229 -0
  68. package/out/sdk/technologies.d.ts.map +1 -0
  69. package/out/sdk/technologies.js +217 -0
  70. package/out/sdk/technologies.js.map +1 -0
  71. package/package.json +97 -0
  72. package/schema.ts +172 -0
  73. package/sdk/build-bdc.ts +810 -0
  74. package/sdk/buildings.ts +87 -0
  75. package/sdk/client.ts +643 -0
  76. package/sdk/common.ts +363 -0
  77. package/sdk/data-collection.ts +268 -0
  78. package/sdk/download.ts +91 -0
  79. package/sdk/filing-dates.ts +88 -0
  80. package/sdk/filing-landscape.ts +287 -0
  81. package/sdk/index.ts +14 -0
  82. package/sdk/list-files.ts +72 -0
  83. package/sdk/location.ts +45 -0
  84. package/sdk/nearest-infrastructure.ts +129 -0
  85. package/sdk/parsing.ts +124 -0
  86. package/sdk/plausibility.ts +512 -0
  87. package/sdk/technologies.ts +258 -0
@@ -0,0 +1,810 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The `bdc.db` builder (2a Task 8) — ingests parsed FCC BDC availability rows (Task 6's
7
+ * {@link BDCAvailabilityRow}) into Task 7's schema (`schema.ts`), producing a sealed layer database.
8
+ *
9
+ * Mirrors `mailwoman/gazetteer-pipeline/poi/build-poi.ts`'s shape closely: the same build-tuning
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.
13
+ *
14
+ * Two differences from that precedent, both deliberate:
15
+ *
16
+ * 1. **A staging-dedup pass.** BDC's per-provider CSVs can carry exact-duplicate rows (repeat
17
+ * filings, overlapping re-downloads); poi's Overture rows never needed this. `bdc_stage` is a
18
+ * plain Kysely-built table (not part of the public {@link BDCDatabase} — it never survives to the
19
+ * sealed artifact) carrying a composite PRIMARY KEY on the natural key
20
+ * `(geoid, provider_id, technology_code, location_id)`. Rows load via a RAW prepared
21
+ * `INSERT OR IGNORE` — the AGENTS.md "hot bulk write" carve-out, same discipline as the
22
+ * candidate-gazetteer builder — which is the direct replacement for the Redis
23
+ * set-membership check Nexus's `sync/commands/bdc/infer-locations.ts` used for this exact
24
+ * dedup (dedup semantics only; Nexus's Redis-backed location inference itself has no analog
25
+ * here). `h3_cell` is computed only AFTER staging, per distinct geoid, against the deduped set —
26
+ * so a duplicate row is never charged twice against `unknownGeoids` or `layer_coverage` either.
27
+ * The natural key's `location_id` component means the SAME (geoid, provider_id, technology_code)
28
+ * triple legitimately survives staging once per distinct BSL — correct when `includeLocationIDs`
29
+ * is true. The default (NULL `location_id`) mode collapses at MATERIALIZE time via `SELECT DISTINCT`
30
+ * over every column EXCEPT `location_id` — that is, to one row per distinct (geoid, provider_id,
31
+ * technology_code, max_advertised_download_speed, max_advertised_upload_speed, low_latency,
32
+ * business_residential_code) TUPLE, NOT one row per (geoid, provider_id, technology_code) triple.
33
+ * When every BSL in a block shares identical speeds/flags for a given provider/technology (the
34
+ * common case), those two are the same thing and the collapse yields exactly one row per triple.
35
+ * But when BSLs at the SAME triple carry DIFFERENT speeds/flags (a real, accepted FCC filing
36
+ * pattern — a provider filing different advertised speeds at different addresses in one block),
37
+ * the distinct rows survive collapse as multiple NULL-`location_id` rows at that one triple. This
38
+ * is deliberate, matches the FCC source data's own granularity, and is NOT a bug to fix — see
39
+ * `filing-landscape.ts`'s module docstring for the read-side consequence (the same provider/tech
40
+ * can surface in more than one `speed_bucket` for one queried block).
41
+ * 2. **Temp-path build + move-aside-first swap**, per AGENTS.md's database house rule ("build
42
+ * successfully, then move the previous version to a temp directory, and then move the new
43
+ * version into place... ensures the database is always in a consistent state, even if the build
44
+ * script fails halfway through"): build lands at `${out}.building`, seals there, then
45
+ * `${out}` (if present) is renamed to `${out}.prev` BEFORE the sealed build takes its place —
46
+ * mirroring `mailwoman/eval-harness/gauntlet/build-regression-db.ts`'s swap. `build-poi.ts`
47
+ * instead writes `out` directly (removing any stale file first) and records ITS OWN deviation
48
+ * from an even older staging-suffix convention — see that file's docstring. This builder takes
49
+ * the opposite fork on purpose: a from-scratch nationwide BDC ingest is long enough that a
50
+ * mid-build crash mustn't cost the previously-good artifact, which is exactly the failure mode
51
+ * the house rule exists for.
52
+ */
53
+
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"
60
+ import {
61
+ createLayerCoverageTable,
62
+ createLayerManifestTable,
63
+ LayerFreshnessPolicy,
64
+ LayerTier,
65
+ writeLayerCoverage,
66
+ writeLayerManifest,
67
+ type LayerContractDatabase,
68
+ } from "@mailwoman/core/layers"
69
+ import { openBuiltDatabase, sealDatabase } from "@mailwoman/core/utils"
70
+ import type { FilerDatabase } from "@mailwoman/filer"
71
+ // `pickPrimaryFRN`/`readFRNFilingCandidates` are loaded via a LAZY `await import("@mailwoman/filer/sdk")`
72
+ // inside `populateBDCProviderTable`, not a top-level runtime import — see that function's docstring
73
+ // (review fix round 1, IMPORTANT-1). Only the TYPES are imported here; `import type` is fully erased, so
74
+ // this line has zero runtime cost for every `@mailwoman/bdc` consumer that never populates providers.
75
+ import type { FRN, ProviderListRow } from "@mailwoman/filer/sdk"
76
+ import { shortCellToInt, type H3Cell } from "@mailwoman/spatial"
77
+ import { cellToParent, latLngToCell } from "h3-js"
78
+ import type { Insertable, Kysely } from "kysely"
79
+
80
+ import {
81
+ BDC_COVERAGE_H3_RESOLUTION,
82
+ BDC_H3_RESOLUTION,
83
+ createBDCAvailabilityTable,
84
+ createBDCGeoidIndex,
85
+ createBDCProviderTable,
86
+ type BDCDatabase,
87
+ type BDCProviderTable,
88
+ } from "../schema.ts"
89
+ import type { ProviderID } from "./common.ts"
90
+ import { takeAvailabilityLine, type BDCAvailabilityRow } from "./parsing.ts"
91
+
92
+ /**
93
+ * Rows committed per `BEGIN`/`COMMIT` batch during both the staging load and the materialize pass — matches
94
+ * `build-poi.ts`'s `STAGE_BATCH_SIZE` discipline.
95
+ */
96
+ const STAGE_BATCH_SIZE = 10_000
97
+
98
+ /**
99
+ * The manifest's `attribution` — names the FCC as the source, then copies the Fabric-boundary sentence verbatim from
100
+ * `bdc/README.md`'s "CostQuest Fabric boundary" section (backticks stripped — this is plain prose, not markdown).
101
+ */
102
+ export const BDC_ATTRIBUTION =
103
+ "FCC Broadband Data Collection. This workspace never ingests, ships, or derives data from the Fabric: " +
104
+ "location_id is carried only as an opaque join key that a licensed user may join against their own Fabric copy."
105
+
106
+ export interface BuildBDCOptions {
107
+ /**
108
+ * Injected row source — the test seam (mirrors `BuildPOIOptions.rows`). When given, `csvPaths` is ignored and no
109
+ * filesystem read happens.
110
+ */
111
+ rows?: Iterable<BDCAvailabilityRow> | AsyncIterable<BDCAvailabilityRow>
112
+ /**
113
+ * Per-provider availability CSVs for one state (as extracted by `downloadBDCFile`). Ignored when `rows` is given.
114
+ * Required unless `rows` is given. Each file's constant `provider_id` column is peeked off its first data row (see
115
+ * {@linkcode peekProviderID}) rather than threaded through as a parallel array — the FCC's per-provider files already
116
+ * carry it once per file, redundantly, in column 1.
117
+ */
118
+ csvPaths?: string[]
119
+ /**
120
+ * Output `bdc.db` path. Built at `${out}.building` and moved into place last — see the module docstring.
121
+ */
122
+ out: string
123
+ /**
124
+ * The FCC filing's `as_of_date` (e.g. from Task 5's `resolveLatestVintage`) — becomes the manifest's `sourceVintage`
125
+ * AND `version` (BDC has no independent layer versioning yet, same deferral `build-poi.ts` makes for `release`).
126
+ */
127
+ asOfDate: string
128
+ /**
129
+ * `git rev-parse --short HEAD` — passed in by the command, not read from the repo here.
130
+ */
131
+ buildSHA: string
132
+ /**
133
+ * Populate `bdc_availability.location_id` (the opaque BSL join key — spec §2.2, NEVER resolved against the Fabric).
134
+ * Default `false`: the column stays `NULL` unless a caller explicitly opts in.
135
+ */
136
+ includeLocationIDs?: boolean
137
+ /**
138
+ * Resolve a 15-char census block GEOID to its centroid. Injected so tests supply a small fixture `Map` lookup instead
139
+ * of touching a real TIGER database; the real (CLI-wired) implementation is
140
+ * {@linkcode createTIGERBlockCentroidLookup}, which reads `tabblock20.GEOID` (uppercase — `TIGERBlockTable`) block
141
+ * geometry. Returning `undefined` for an unknown geoid is load-bearing: the materialize pass counts it in
142
+ * `unknownGeoids` and skips the row — it must NEVER guess a cell.
143
+ */
144
+ blockCentroids: (geoid: string) => { lat: number; lon: number } | undefined
145
+ onProgress?: (message: string) => void
146
+ /**
147
+ * Provider-list rows (Task 3's {@link ProviderListRow}, `@mailwoman/filer/sdk`'s `parseProviderList`) — the test/CLI
148
+ * seam for populating `bdc_provider` (2a decision 8 / 3a decision 6, Task 8). When ABSENT (the default),
149
+ * `bdc_provider` stays empty and the rest of the build is byte-identical to the pre-Task-8 behavior: every code path
150
+ * this option touches is gated behind `if (options.providers)`, so omitting it changes nothing. When present,
151
+ * `buildBDCDatabase` groups rows by `providerID` and inserts one `bdc_provider` row per distinct provider — see
152
+ * {@link BuildBDCOptions.filerDB} for how the primary FRN is picked when a provider carries more than one, and
153
+ * `schema.ts`'s `BDCProviderTable` docstring for the full lossy-denormalization rationale (decision 6).
154
+ */
155
+ providers?: Iterable<ProviderListRow> | AsyncIterable<ProviderListRow>
156
+ /**
157
+ * Filer.db handle (`@mailwoman/filer`) used to resolve a multi-FRN provider's PRIMARY FRN via
158
+ * `readFRNFilingCandidates` + `pickPrimaryFRN` (`@mailwoman/filer/sdk`, decision 6) — imported rather than
159
+ * reimplemented, since Task 7's review found and fixed a temporal bug (the candidate query originally omitted the
160
+ * `valid_to` half of the half-open predicate) that a fresh implementation here would reintroduce. Only actually
161
+ * QUERIED for a `provider_id` whose rows carry more than one distinct `frn` — a single-FRN provider needs no lookup,
162
+ * since its lone FRN is already primary by construction. Required whenever `providers` is given AND at least one
163
+ * `provider_id` turns out to be multi-FRN; `buildBDCDatabase` throws a descriptive error naming the offending
164
+ * `provider_id` if it's needed but missing, rather than silently picking an arbitrary FRN.
165
+ */
166
+ filerDB?: DatabaseClient<FilerDatabase>
167
+ /**
168
+ * `asOf` date for the primary-FRN candidate query (`readFRNFilingCandidates`'s half-open `valid_from`/`valid_to`
169
+ * scoping — see `filer/sdk/filer-lookup.ts`). Defaults to {@link BuildBDCOptions.asOfDate} (this bdc.db build's own
170
+ * vintage) when omitted.
171
+ */
172
+ primaryFRNAsOf?: string
173
+ }
174
+
175
+ export interface BuildBDCResult {
176
+ out: string
177
+ /**
178
+ * Rows materialized into `bdc_availability` (post-dedup, post-unknown-geoid-skip). In the default
179
+ * (`includeLocationIDs: false`) mode this is per DISTINCT (geoid, provider_id, technology_code, speeds, low_latency,
180
+ * business_residential_code) tuple, not per BSL — multiple BSLs at the same (geoid, provider_id, technology_code)
181
+ * triple collapse to one row ONLY when their speeds/flags also match; BSLs at the same triple with differing
182
+ * speeds/flags survive as separate rows (see the module docstring).
183
+ */
184
+ rows: number
185
+ /**
186
+ * Raw rows removed by the staging natural-key dedup (staged attempts minus distinct rows kept).
187
+ */
188
+ deduped: number
189
+ /**
190
+ * Distinct `provider_id` values among the materialized rows.
191
+ */
192
+ providers: number
193
+ /**
194
+ * Res-6 coverage cells written.
195
+ */
196
+ coverageCells: number
197
+ /**
198
+ * Rows whose `geoid` had no resolvable centroid — skipped, never inserted, never guessed at a cell.
199
+ */
200
+ unknownGeoids: number
201
+ /**
202
+ * `bdc_provider` rows written — 0 when `options.providers` was not supplied (the default path never touches this
203
+ * table, see {@link BuildBDCOptions.providers}).
204
+ */
205
+ providersPopulated: number
206
+ }
207
+
208
+ /**
209
+ * `BDCDatabase extends LayerContractDatabase` structurally, but Kysely's `transaction()` makes `Kysely<DB>` INVARIANT
210
+ * in `DB` — narrows a `DatabaseClient<BDCDatabase>` handle back down for the `@mailwoman/core/layers` calls. Exact
211
+ * precedent: `build-poi.ts`'s own `asContractDB`; see that file for the full rationale (tried widening the shared
212
+ * package's signatures first — breaks THEIR internal `insertInto`/`selectFrom` calls instead).
213
+ */
214
+ function asContractDB(kdb: DatabaseClient<BDCDatabase>): DatabaseClient<LayerContractDatabase> {
215
+ return kdb as unknown as DatabaseClient<LayerContractDatabase>
216
+ }
217
+
218
+ /**
219
+ * Create the build-only `bdc_stage` table — deliberately NOT part of the public {@link BDCDatabase} interface (it's
220
+ * dropped before the artifact seals, so it never appears in the shipped schema). Built via Kysely's schema builder per
221
+ * the AGENTS.md DDL convention (`createTable` takes any string table name — it doesn't need to be a `keyof DB` to
222
+ * type-check); all of `bdc_stage`'s actual reads/writes below go through raw `.prepare()` on the shared `DatabaseSync`
223
+ * instead, per the "hot bulk write" carve-out.
224
+ *
225
+ * The composite PRIMARY KEY on the natural key is what makes `INSERT OR IGNORE` a dedup: SQLite silently drops any
226
+ * insert whose key already exists rather than raising the constraint violation.
227
+ */
228
+ async function createBDCStageTable(db: Kysely<BDCDatabase>): Promise<void> {
229
+ await db.schema
230
+ .createTable("bdc_stage")
231
+ .addColumn("geoid", "text", (c) => c.notNull())
232
+ .addColumn("provider_id", "integer", (c) => c.notNull())
233
+ .addColumn("technology_code", "integer", (c) => c.notNull())
234
+ .addColumn("location_id", "text", (c) => c.notNull())
235
+ .addColumn("max_advertised_download_speed", "integer", (c) => c.notNull())
236
+ .addColumn("max_advertised_upload_speed", "integer", (c) => c.notNull())
237
+ .addColumn("low_latency", "integer", (c) => c.notNull())
238
+ .addColumn("business_residential_code", "text", (c) => c.notNull())
239
+ .addPrimaryKeyConstraint("bdc_stage_pk", ["geoid", "provider_id", "technology_code", "location_id"])
240
+ .execute()
241
+ }
242
+
243
+ /**
244
+ * A `bdc_stage` row, as read back by the materialize pass. When `includeLocationIDs` is true, `location_id` is
245
+ * populated (one row per distinct BSL). When false (default), the materialize query collapses on every column EXCEPT
246
+ * `location_id` (see {@linkcode buildBDCDatabase}'s materialize step), so `location_id` is simply absent from that
247
+ * query and never read.
248
+ */
249
+ interface BDCStageRow {
250
+ geoid: string
251
+ provider_id: number
252
+ technology_code: number
253
+ location_id?: string
254
+ max_advertised_download_speed: number
255
+ max_advertised_upload_speed: number
256
+ low_latency: 0 | 1
257
+ business_residential_code: string
258
+ }
259
+
260
+ /**
261
+ * Peek the constant `provider_id` column (index 1) off an FCC BDC availability CSV's first data row.
262
+ *
263
+ * Production per-provider files carry the SAME `provider_id` in every row (the FCC partitions availability files per
264
+ * provider) — `parsing.ts`'s `takeAvailabilityLine` already assumes this, taking `providerID` as a parameter rather
265
+ * than re-slicing column 1 per row. This reads it once, directly off the raw bytes, rather than threading a parallel
266
+ * `providerID` array alongside `csvPaths` through the public options shape.
267
+ *
268
+ * `csvPath` is optional and used ONLY to name the offending file in a thrown error (the direct-buffer unit tests call
269
+ * this without one; {@linkcode readAvailabilityRowsFromCSVPaths} always supplies it). The `Number.isSafeInteger` guard
270
+ * below is load-bearing, not defensive dressing: `bdc_stage.provider_id` is `INTEGER NOT NULL`, and a bare
271
+ * `Number.parseInt` on a non-numeric field (a malformed/re-headered/truncated CSV) silently produces `NaN`. `NaN` binds
272
+ * to that NOT NULL column as SQLite `NULL`, `INSERT OR IGNORE` then drops the row without a constraint error, and every
273
+ * dropped row gets counted as `deduped` — the ENTIRE file's rows vanish silently, misreported as ordinary dedup. A
274
+ * malformed CSV must be loud, never silently absorbed, so this throws instead.
275
+ */
276
+ export function peekProviderID(csvBuffer: Buffer, csvPath?: string): ProviderID {
277
+ const headerEnd = csvBuffer.indexOf(0x0a)
278
+ const fileSuffix = csvPath ? ` (${csvPath})` : ""
279
+
280
+ if (headerEnd === -1) {
281
+ throw new Error(`peekProviderID: no newline found — empty or header-only CSV buffer${fileSuffix}`)
282
+ }
283
+
284
+ const nextNewline = csvBuffer.indexOf(0x0a, headerEnd + 1)
285
+
286
+ const firstDataLine = csvBuffer
287
+ .subarray(headerEnd + 1, nextNewline === -1 ? undefined : nextNewline)
288
+ .toString("ascii")
289
+
290
+ const providerIDField = firstDataLine.split(",")[1]
291
+
292
+ if (!providerIDField) {
293
+ throw new Error(`peekProviderID: could not read provider_id (column 1) from the first data row${fileSuffix}`)
294
+ }
295
+
296
+ const providerID = Number.parseInt(providerIDField, 10)
297
+
298
+ if (!Number.isSafeInteger(providerID)) {
299
+ throw new TypeError(
300
+ `peekProviderID: provider_id column (1) did not parse to a safe integer — got ${JSON.stringify(providerIDField)}` +
301
+ `${fileSuffix}. Refusing to silently drop this file's rows: an unguarded NaN binds to bdc_stage.provider_id ` +
302
+ `(INTEGER NOT NULL) as SQLite NULL, and INSERT OR IGNORE would then discard every row uncounted as ordinary dedup.`
303
+ )
304
+ }
305
+
306
+ return providerID as ProviderID
307
+ }
308
+
309
+ /**
310
+ * Reads each of `csvPaths` fully into memory, peeks its `provider_id` ({@linkcode peekProviderID}, passing the path
311
+ * through so a malformed file's error names it), then yields every row via `takeAvailabilityLine`. This is the
312
+ * production counterpart to the test seam's injected `rows` — exercised by `build-bdc.test.ts` only for the
313
+ * malformed-provider-id rejection path, same as `build-poi.ts`'s `readParquetRows`.
314
+ */
315
+ async function* readAvailabilityRowsFromCSVPaths(csvPaths: readonly string[]): AsyncIterable<BDCAvailabilityRow> {
316
+ for (const csvPath of csvPaths) {
317
+ const buffer = await readFile(csvPath)
318
+ const providerID = peekProviderID(buffer, csvPath)
319
+
320
+ yield* takeAvailabilityLine(buffer, providerID)
321
+ }
322
+ }
323
+
324
+ /**
325
+ * One GeoJSON `Polygon`/`MultiPolygon` geometry, as stored in `tabblock20.geometry` (see `tiger/sdk/schema.ts`).
326
+ */
327
+ interface GeoJSONPolygon {
328
+ type: "Polygon"
329
+ coordinates: number[][][]
330
+ }
331
+
332
+ interface GeoJSONMultiPolygon {
333
+ type: "MultiPolygon"
334
+ coordinates: number[][][][]
335
+ }
336
+
337
+ /**
338
+ * Naive (vertex-average, NOT area-weighted) centroid of a GeoJSON `Polygon`/`MultiPolygon`'s EXTERIOR ring(s) only
339
+ * (interior rings/holes are ignored). A known simplification, not an oversight: census blocks are small relative to a
340
+ * res-9 H3 cell (~174m edge), so the vertex-average and a proper area-weighted centroid land in the same cell for all
341
+ * but pathologically elongated or holed block shapes. A precise area-weighted centroid is a reasonable future upgrade
342
+ * if that ever proves wrong in practice — no polygon-centroid library is pulled in for this first cut.
343
+ *
344
+ * Returns `undefined` for anything that doesn't parse as one of the two geometry types (including `null` geometry).
345
+ */
346
+ export function geometryCentroid(geometryJSON: string | null): { lat: number; lon: number } | undefined {
347
+ if (!geometryJSON) return undefined
348
+
349
+ let geometry: GeoJSONPolygon | GeoJSONMultiPolygon
350
+
351
+ try {
352
+ geometry = JSON.parse(geometryJSON)
353
+ } catch {
354
+ return undefined
355
+ }
356
+
357
+ const exteriorRings: number[][][] =
358
+ geometry.type === "Polygon"
359
+ ? [geometry.coordinates[0] ?? []]
360
+ : geometry.type === "MultiPolygon"
361
+ ? geometry.coordinates.map((polygon) => polygon[0] ?? [])
362
+ : []
363
+
364
+ let sumLon = 0
365
+ let sumLat = 0
366
+ let count = 0
367
+
368
+ for (const ring of exteriorRings) {
369
+ for (const point of ring) {
370
+ const [lon, lat] = point
371
+
372
+ if (typeof lon !== "number" || typeof lat !== "number") continue
373
+
374
+ sumLon += lon
375
+ sumLat += lat
376
+
377
+ count++
378
+ }
379
+ }
380
+
381
+ if (count === 0) return undefined
382
+
383
+ return { lat: sumLat / count, lon: sumLon / count }
384
+ }
385
+
386
+ /**
387
+ * The production `blockCentroids` supplier: opens the TIGER blocks database READ-ONLY and probes `tabblock20.GEOID`
388
+ * (uppercase) per lookup, decoding its GeoJSON `geometry` column via {@linkcode geometryCentroid}. Kept synchronous —
389
+ * `BuildBDCOptions.blockCentroids` is a plain sync function (the same sync-by-interface discipline AGENTS.md documents
390
+ * for the resolver ladder), so this uses `node:sqlite`'s raw `.prepare()`/`.get()` directly rather than Kysely. The
391
+ * connection is left open for the caller's process lifetime (a read-path lookup, not a build) — same lifecycle as the
392
+ * resolver-wof-sqlite lookups.
393
+ */
394
+ export function createTIGERBlockCentroidLookup(
395
+ tigerDBPath: string
396
+ ): (geoid: string) => { lat: number; lon: number } | undefined {
397
+ const db = openBuiltDatabase(tigerDBPath)
398
+ const stmt = db.prepare("SELECT geometry FROM tabblock20 WHERE GEOID = ?")
399
+
400
+ return (geoid: string) => {
401
+ const row = stmt.get(geoid) as { geometry: string | null } | undefined
402
+
403
+ return row ? geometryCentroid(row.geometry) : undefined
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Rows per `INSERT` batch when populating `bdc_provider`. Far smaller than {@link STAGE_BATCH_SIZE}: that constant
409
+ * tunes `bdc_availability`'s multi-million-row raw-prepared-statement path, whereas `bdc_provider` is a small
410
+ * per-provider dictionary (thousands of rows, not millions) inserted through Kysely's typed `insertInto` — this batches
411
+ * only to stay comfortably under SQLite's bound-parameter ceiling, not for throughput.
412
+ */
413
+ const PROVIDER_INSERT_BATCH_SIZE = 500
414
+
415
+ /**
416
+ * Groups `providers` by `providerID`. Task 3's `parseProviderList` yields one {@link ProviderListRow} PER LINE of the
417
+ * source CSV, preserving cardinality (never folded, never last-wins — see that module's docstring) — so a `provider_id`
418
+ * appearing on N rows arrives here as N separate rows, exactly as decision 6 requires downstream.
419
+ */
420
+ async function groupProviderListRows(
421
+ providers: Iterable<ProviderListRow> | AsyncIterable<ProviderListRow>
422
+ ): Promise<Map<number, ProviderListRow[]>> {
423
+ const byProviderID = new Map<number, ProviderListRow[]>()
424
+
425
+ for await (const row of providers) {
426
+ const rows = byProviderID.get(row.providerID)
427
+
428
+ if (rows) {
429
+ rows.push(row)
430
+ } else {
431
+ byProviderID.set(row.providerID, [row])
432
+ }
433
+ }
434
+
435
+ return byProviderID
436
+ }
437
+
438
+ /**
439
+ * Populate `bdc_provider` from `options.providers` (2a decision 8 / 3a decision 6, Task 8) — see `schema.ts`'s
440
+ * `BDCProviderTable` docstring for the full lossy-denormalization rationale. For each distinct `provider_id`:
441
+ *
442
+ * - Exactly one `frn` among its rows → that FRN is primary by construction; no `filerDB` query needed at all.
443
+ * - More than one distinct `frn` → `readFRNFilingCandidates` (`@mailwoman/filer/sdk`, lazily imported — see below) reads
444
+ * each FRN's own most recent IN-FORCE `form-499` filing edge from `filerDB`, `asOf` the given date, and
445
+ * `pickPrimaryFRN` picks the winner (decision 6: most recent 499 filing date wins). A `provider_id` whose FRNs carry
446
+ * NO 499 filing to rank by inserts `frn: NULL` rather than guessing — `pickPrimaryFRN` throws on empty input, so this
447
+ * checks `candidates.length` first, mirroring `filerLookup`'s own `primary_frn: null` handling of the same case.
448
+ * - `filerDB` is REQUIRED the instant a multi-FRN `provider_id` is encountered; its absence throws immediately, naming
449
+ * the offending `provider_id`, rather than silently picking an arbitrary FRN.
450
+ * - `holding_company` gets the IDENTICAL single-distinct-value shortcut `frn` gets (review fix round 1, IMPORTANT-3):
451
+ * exactly one distinct non-null `holdingCompany` across a provider's rows means there's no conflict to resolve, so
452
+ * it's populated directly, no rule needed. Two or more distinct values IS the real conflict decision 6 refuses to
453
+ * paper over with last-wins — that case inserts NULL, and every value stays recoverable from `filer.db`. A `null`
454
+ * `holdingCompany` on some rows doesn't count as a competing value (a row simply not stating it isn't a conflicting
455
+ * assertion) — only distinct NON-NULL strings are compared.
456
+ *
457
+ * `brand_name` is always inserted NULL — the provider list carries no brand-name column at all, so there is nothing to
458
+ * populate it from, primary or otherwise (see the schema docstring).
459
+ *
460
+ * **Lazy `@mailwoman/filer/sdk` import (review fix round 1, IMPORTANT-1).** `readFRNFilingCandidates`/`pickPrimaryFRN`
461
+ * are loaded via `await import("@mailwoman/filer/sdk")`, memoized in `filerSDK` below, rather than a top-level static
462
+ * import — that barrel re-exports `cluster-filers.ts`, which pulls in `@mailwoman/match`/`record`/`registry`. A
463
+ * top-level import regressed `@mailwoman/bdc`'s import time ~32% for EVERY consumer, including ones that never populate
464
+ * providers at all; the dynamic import here only ever runs when a multi-FRN `provider_id` is actually encountered, so a
465
+ * `providers`-less build (or one whose providers are all single-FRN) pays nothing.
466
+ */
467
+ async function populateBDCProviderTable(
468
+ kdb: DatabaseClient<BDCDatabase>,
469
+ providers: Iterable<ProviderListRow> | AsyncIterable<ProviderListRow>,
470
+ filerDB: DatabaseClient<FilerDatabase> | undefined,
471
+ asOf: string
472
+ ): Promise<number> {
473
+ const byProviderID = await groupProviderListRows(providers)
474
+ const insertRows: Insertable<BDCProviderTable>[] = []
475
+
476
+ let filerSDK: typeof import("@mailwoman/filer/sdk") | undefined
477
+
478
+ for (const [providerID, rows] of byProviderID) {
479
+ const distinctFRNs = [...new Set(rows.map((row) => row.frn))]
480
+
481
+ let frn: FRN | null
482
+
483
+ if (distinctFRNs.length === 1) {
484
+ frn = distinctFRNs[0]!
485
+ } else {
486
+ if (!filerDB) {
487
+ throw new Error(
488
+ `buildBDCDatabase: provider_id ${providerID} carries ${distinctFRNs.length} distinct FRNs across the ` +
489
+ "provider list — resolving the primary FRN (decision 6) requires `filerDB` to be supplied " +
490
+ "alongside `providers`"
491
+ )
492
+ }
493
+
494
+ filerSDK ??= await import("@mailwoman/filer/sdk")
495
+
496
+ const candidates = await filerSDK.readFRNFilingCandidates(filerDB, distinctFRNs, asOf)
497
+
498
+ frn = candidates.length ? filerSDK.pickPrimaryFRN(candidates) : null
499
+ }
500
+
501
+ const distinctHoldingCompanies = [
502
+ ...new Set(rows.map((row) => row.holdingCompany).filter((value): value is string => value !== null)),
503
+ ]
504
+
505
+ const holdingCompany = distinctHoldingCompanies.length === 1 ? distinctHoldingCompanies[0]! : null
506
+
507
+ insertRows.push({ provider_id: providerID, frn, brand_name: null, holding_company: holdingCompany })
508
+ }
509
+
510
+ for (let index = 0; index < insertRows.length; index += PROVIDER_INSERT_BATCH_SIZE) {
511
+ await kdb
512
+ .insertInto("bdc_provider")
513
+ .values(insertRows.slice(index, index + PROVIDER_INSERT_BATCH_SIZE))
514
+ .execute()
515
+ }
516
+
517
+ return insertRows.length
518
+ }
519
+
520
+ /**
521
+ * Build `bdc.db`: stage (raw dedup) → materialize (resolve `h3_cell` per geoid, skip+count unknown geoids) → drop stage
522
+ * → geoid index (index-after-load) → coverage → layer manifest → seal → atomic move-into-place. See the module
523
+ * docstring for the two deliberate deviations from `build-poi.ts`.
524
+ */
525
+ export async function buildBDCDatabase(options: BuildBDCOptions): Promise<BuildBDCResult> {
526
+ const progress = options.onProgress ?? (() => {})
527
+
528
+ if (!options.rows && (!options.csvPaths || !options.csvPaths.length)) {
529
+ throw new Error(
530
+ "buildBDCDatabase: pass either `rows` (test/injected source) or `csvPaths` (per-provider availability CSVs)"
531
+ )
532
+ }
533
+
534
+ const buildingPath = `${options.out}.building`
535
+
536
+ if (existsSync(buildingPath)) {
537
+ rmSync(buildingPath)
538
+ }
539
+
540
+ mkdirSync(dirname(options.out), { recursive: true })
541
+
542
+ const rowSource: AsyncIterable<BDCAvailabilityRow> | Iterable<BDCAvailabilityRow> =
543
+ options.rows ?? readAvailabilityRowsFromCSVPaths(options.csvPaths!)
544
+
545
+ const db = new DatabaseSync(buildingPath)
546
+ // Build-tuning pragmas — identical to build-poi.ts's discipline.
547
+ db.exec("PRAGMA page_size=8192; PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA cache_size=-2000000;")
548
+ const kdb = new DatabaseClient<BDCDatabase>({ database: db })
549
+
550
+ progress("creating manifest/coverage/availability/provider/stage tables")
551
+ await createLayerManifestTable(asContractDB(kdb))
552
+ await createLayerCoverageTable(asContractDB(kdb))
553
+ await createBDCAvailabilityTable(kdb)
554
+ await createBDCProviderTable(kdb)
555
+ await createBDCStageTable(kdb)
556
+
557
+ const insStage = db.prepare(
558
+ `INSERT OR IGNORE INTO bdc_stage (
559
+ geoid, provider_id, technology_code, location_id,
560
+ max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code
561
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
562
+ )
563
+
564
+ let staged = 0
565
+ let batch = 0
566
+
567
+ progress("staging rows — raw prepared INSERT OR IGNORE on the natural key (the Redis-dedup replacement)")
568
+ db.exec("BEGIN")
569
+
570
+ for await (const row of rowSource) {
571
+ insStage.run(
572
+ row.geoid,
573
+ row.provider_id,
574
+ row.technology_code,
575
+ row.location_id,
576
+ row.max_advertised_download_speed,
577
+ row.max_advertised_upload_speed,
578
+ row.low_latency,
579
+ row.business_residential_code
580
+ )
581
+
582
+ staged++
583
+
584
+ batch++
585
+
586
+ if (batch >= STAGE_BATCH_SIZE) {
587
+ db.exec("COMMIT")
588
+ db.exec("BEGIN")
589
+ batch = 0
590
+ }
591
+ }
592
+
593
+ db.exec("COMMIT")
594
+
595
+ const stagedCountRow = db.prepare("SELECT COUNT(*) AS staged_count FROM bdc_stage").get() as {
596
+ staged_count: number
597
+ }
598
+
599
+ const deduped = staged - stagedCountRow.staged_count
600
+
601
+ progress(
602
+ `staged ${stagedCountRow.staged_count.toLocaleString()} distinct row(s), ${deduped.toLocaleString()} deduped`
603
+ )
604
+
605
+ const centroidCache = new Map<string, { h3Cell: number; coverageCell: number } | null>()
606
+ /**
607
+ * Res-6 short-cell int → observed row count, aggregated during materialize (one pass, no second scan) — matches
608
+ * `build-poi.ts`'s `coverage` Map.
609
+ */
610
+ const coverage = new Map<number, number>()
611
+ const providers = new Set<number>()
612
+ let unknownGeoids = 0
613
+ let inserted = 0
614
+
615
+ const insAvailability = db.prepare(
616
+ `INSERT INTO bdc_availability (
617
+ h3_cell, geoid, wof_id, provider_id, technology_code,
618
+ max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code, location_id
619
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
620
+ )
621
+
622
+ // The FCC's per-provider CSVs are per-BSL: the SAME (geoid, provider_id, technology_code, speeds, low_latency,
623
+ // business_residential_code) tuple can repeat once per Broadband Serviceable Location within that block (a
624
+ // dense urban block can carry ~100 BSLs) — `bdc_stage`'s natural key includes `location_id`, so those BSL rows
625
+ // all survive the staging dedup as distinct staged rows. In `includeLocationIDs` mode that's correct: every BSL
626
+ // is a real, distinct row the caller asked to keep. In the default (NULL `location_id`) mode, when those BSLs
627
+ // ALSO share identical speeds/flags, they'd otherwise materialize as byte-identical rows, inflating `result.rows`
628
+ // and `layer_coverage.observed_rows` by the BSL count (found in review, ~100x at real scale) — `SELECT DISTINCT`
629
+ // over every column EXCEPT `location_id` collapses those byte-identical BSL duplicates down to one row.
630
+ // IMPORTANT — this is NOT a guarantee of one row per (geoid, provider_id, technology_code) triple: BSLs at the
631
+ // same triple with DIFFERING speeds/flags are NOT the same tuple, so `SELECT DISTINCT` does not merge them —
632
+ // they survive as multiple NULL-`location_id` rows at that one triple. Accepted, not a bug; see the module
633
+ // docstring and `filing-landscape.ts`'s docstring for the read-side consequence.
634
+ const stageStmt = options.includeLocationIDs
635
+ ? db.prepare(
636
+ `SELECT geoid, provider_id, technology_code, location_id,
637
+ max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code
638
+ FROM bdc_stage`
639
+ )
640
+ : db.prepare(
641
+ `SELECT DISTINCT geoid, provider_id, technology_code,
642
+ max_advertised_download_speed, max_advertised_upload_speed, low_latency, business_residential_code
643
+ FROM bdc_stage`
644
+ )
645
+
646
+ progress(
647
+ "materializing bdc_availability — resolving block centroids to h3_cell (unknown geoids skipped, never guessed)"
648
+ )
649
+
650
+ db.exec("BEGIN")
651
+ batch = 0
652
+
653
+ for (const row of stageStmt.iterate() as IterableIterator<BDCStageRow>) {
654
+ let resolved = centroidCache.get(row.geoid)
655
+
656
+ if (resolved === undefined) {
657
+ const centroid = options.blockCentroids(row.geoid)
658
+
659
+ resolved = centroid
660
+ ? (() => {
661
+ // Coverage cell MUST be derived as the res-9 cell's H3 hierarchy parent — NOT a second,
662
+ // independent `latLngToCell(centroid, 6)` call. H3's cell hierarchy is not geometrically
663
+ // exact: a point's directly-indexed res-6 cell and its res-9 cell's `cellToParent(…, 6)`
664
+ // disagree for a real fraction of points (~6% empirically over CONUS, reviewer-verified —
665
+ // hexagon/pentagon boundary artifacts). Deriving both `h3_cell` and the coverage cell from
666
+ // the SAME full res-9 index is what lets `filing-landscape.ts`'s reader reconstruct this
667
+ // exact coverage cell from nothing but the stored `h3_cell` (its `res9ShortCellToRes6Parent`
668
+ // applies `cellToParent` to the reconstructed res-9 cell) — builder and reader must derive
669
+ // the res-6 parent identically, or a genuinely-surveyed block can read back as unknown.
670
+ const fullRes9Cell = latLngToCell(centroid.lat, centroid.lon, BDC_H3_RESOLUTION) as H3Cell
671
+
672
+ return {
673
+ h3Cell: shortCellToInt(fullRes9Cell),
674
+ coverageCell: shortCellToInt(cellToParent(fullRes9Cell, BDC_COVERAGE_H3_RESOLUTION) as H3Cell),
675
+ }
676
+ })()
677
+ : null
678
+
679
+ centroidCache.set(row.geoid, resolved)
680
+ }
681
+
682
+ if (!resolved) {
683
+ unknownGeoids++
684
+
685
+ continue
686
+ }
687
+
688
+ insAvailability.run(
689
+ resolved.h3Cell,
690
+ row.geoid,
691
+ // wof_id stays NULL here — WOF point-in-polygon resolution against the block centroid is a later
692
+ // registry-join task, the same decision-8 scoping schema.ts documents for `bdc_provider`.
693
+ null,
694
+ row.provider_id,
695
+ row.technology_code,
696
+ row.max_advertised_download_speed,
697
+ row.max_advertised_upload_speed,
698
+ row.low_latency,
699
+ row.business_residential_code,
700
+ options.includeLocationIDs ? (row.location_id ?? null) : null
701
+ )
702
+
703
+ inserted++
704
+ providers.add(row.provider_id)
705
+ coverage.set(resolved.coverageCell, (coverage.get(resolved.coverageCell) ?? 0) + 1)
706
+
707
+ batch++
708
+
709
+ if (batch >= STAGE_BATCH_SIZE) {
710
+ db.exec("COMMIT")
711
+ db.exec("BEGIN")
712
+ batch = 0
713
+ }
714
+ }
715
+
716
+ db.exec("COMMIT")
717
+
718
+ progress(
719
+ `materialized ${inserted.toLocaleString()} row(s) across ${providers.size} provider(s) ` +
720
+ `(${unknownGeoids.toLocaleString()} unknown geoid(s) skipped)`
721
+ )
722
+
723
+ await kdb.schema.dropTable("bdc_stage").execute()
724
+
725
+ progress("geoid index (index-after-load — see schema.ts)")
726
+ await createBDCGeoidIndex(kdb)
727
+
728
+ // Coverage is SOURCE-LEVEL, not survey completeness — same convention build-poi.ts documents: a res-6 cell we
729
+ // have availability rows in is recorded at completeness 1.0. A cell absent from `layer_coverage` means no rows
730
+ // were observed there at all (the meaning-of-zero rule — missing = unknown, never `{completeness: 0}`).
731
+ const coverageCells = [...coverage.entries()].map(([h3Cell, observedRows]) => ({
732
+ h3Cell,
733
+ completeness: 1,
734
+ observedRows,
735
+ }))
736
+
737
+ await writeLayerCoverage(asContractDB(kdb), coverageCells)
738
+
739
+ progress("writing layer manifest")
740
+
741
+ await writeLayerManifest(asContractDB(kdb), {
742
+ name: "bdc",
743
+ version: options.asOfDate,
744
+ schemaVersion: 1,
745
+ tier: LayerTier.Shipped,
746
+ license: "public-domain",
747
+ attribution: BDC_ATTRIBUTION,
748
+ source: "fcc-bdc",
749
+ sourceVintage: options.asOfDate,
750
+ buildCmd: "mailwoman gazetteer build bdc",
751
+ buildSHA: options.buildSHA,
752
+ freshnessPolicy: LayerFreshnessPolicy.VersionedRefresh,
753
+ spineKeys: { h3: { column: "h3_cell", resolution: BDC_H3_RESOLUTION }, wofID: "wof_id" },
754
+ createdAt: new Date().toISOString(),
755
+ })
756
+
757
+ // bdc_provider population (2a decision 8 / 3a decision 6, Task 8) — entirely additive and gated behind
758
+ // `options.providers`: when absent, this block never runs and `bdc_provider` stays empty, exactly as before
759
+ // Task 8 (see `BuildBDCOptions.providers`'s docstring — the default-path byte-identical guarantee).
760
+ let providersPopulated = 0
761
+
762
+ if (options.providers) {
763
+ progress("populating bdc_provider from the provider list (decision 6 — lossy denormalization, see schema.ts)")
764
+
765
+ providersPopulated = await populateBDCProviderTable(
766
+ kdb,
767
+ options.providers,
768
+ options.filerDB,
769
+ options.primaryFRNAsOf ?? options.asOfDate
770
+ )
771
+
772
+ progress(`bdc_provider: ${providersPopulated.toLocaleString()} provider(s) populated`)
773
+ }
774
+
775
+ progress("finalize: ANALYZE + VACUUM")
776
+ db.exec("ANALYZE")
777
+ // page_size MUST be set right before VACUUM — node:sqlite initializes the file at the 4096 default on
778
+ // `new DatabaseSync`, so the earlier pragma is a no-op until a VACUUM rebuilds at the new size (build-poi.ts's
779
+ // same discipline).
780
+ db.exec("PRAGMA page_size=8192")
781
+ db.exec("VACUUM")
782
+ await kdb.destroy()
783
+
784
+ progress("seal")
785
+ sealDatabase(buildingPath)
786
+
787
+ // Atomic move-into-place — the previous version is moved ASIDE FIRST, per the AGENTS.md database house rule
788
+ // ("build successfully, then move the previous version to a temp directory, and then move the new version into
789
+ // place"). Mirrors `mailwoman/eval-harness/gauntlet/build-regression-db.ts`'s `${output}.prev` swap. Deliberate
790
+ // deviation from `build-poi.ts`'s direct-write — see the module docstring.
791
+ if (existsSync(options.out)) {
792
+ renameSync(options.out, `${options.out}.prev`)
793
+ }
794
+
795
+ renameSync(buildingPath, options.out)
796
+
797
+ if (existsSync(`${options.out}.prev`)) {
798
+ rmSync(`${options.out}.prev`)
799
+ }
800
+
801
+ return {
802
+ out: options.out,
803
+ rows: inserted,
804
+ deduped,
805
+ providers: providers.size,
806
+ coverageCells: coverageCells.length,
807
+ unknownGeoids,
808
+ providersPopulated,
809
+ }
810
+ }