@mailwoman/bdc 9.0.0 → 9.2.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/sdk/download.ts CHANGED
@@ -3,56 +3,16 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  * @file FCC BDC availability-file download + zip extraction.
6
- *
7
- * Re-homed from Nexus's `sync/fcc/bdc/download-file.ts` (relicense-by-copy, no provenance headers),
8
- * trimmed hard: the Nexus original downloaded AND cached the `.zip`, extracted it, THEN wrote a Parquet
9
- * file with a row-count integrity check. All Parquet machinery is dropped here — `bdc.db` is
10
- * SQLite, not Parquet-backed (`bdc/schema.ts`) — and the `.zip` itself isn't cached either; only the
11
- * extracted CSV is written to `destinationDir`, and its presence alone is the cache check.
12
- *
13
- * The zip-extraction library also changes: the Nexus original's `extractSingleFileZip` used `adm-zip`
14
- * (a repo-wide Nexus dependency). No unzip dependency exists anywhere in this repo — every workspace
15
- * `package.json` was checked, `tiger/` and `osm/` included — so `yauzl-promise` is a `bdc`-only
16
- * dependency.
17
6
  */
18
7
 
19
8
  import * as fs from "node:fs/promises"
20
9
  import * as path from "node:path"
21
10
 
22
- import { fromBuffer } from "yauzl-promise"
11
+ import { extractSingleFileZip } from "@mailwoman/core/fs/zip"
23
12
 
24
13
  import type { BDCClient } from "./client.ts"
25
14
  import { BDCFilingDataType, type BDCFile } from "./common.ts"
26
15
 
27
- /**
28
- * Extract the first file entry of a zip archive buffer into a single in-memory `Buffer`.
29
- *
30
- * BDC availability downloads are always a single zip-wrapped CSV, so — like the Nexus original — this doesn't walk
31
- * every entry, just the first non-directory one.
32
- */
33
- async function extractSingleFileZip(zippedBuffer: Buffer): Promise<Buffer> {
34
- const zip = await fromBuffer(zippedBuffer)
35
-
36
- try {
37
- for await (const entry of zip) {
38
- if (entry.filename.endsWith("/")) continue
39
-
40
- const readStream = await entry.openReadStream()
41
- const chunks: Buffer[] = []
42
-
43
- for await (const chunk of readStream) {
44
- chunks.push(chunk)
45
- }
46
-
47
- return Buffer.concat(chunks)
48
- }
49
-
50
- throw new Error("extractSingleFileZip: no file entries found in zip archive.")
51
- } finally {
52
- await zip.close()
53
- }
54
- }
55
-
56
16
  /**
57
17
  * Download and cache an FCC BDC availability file, extracting its zip-wrapped CSV to `destinationDir`.
58
18
  *
@@ -34,10 +34,10 @@
34
34
  */
35
35
 
36
36
  import type { DatabaseClient } from "@mailwoman/core/kysley/client"
37
- import { readLayerCoverage, readLayerManifest, type LayerContractDatabase } from "@mailwoman/core/layers"
37
+ import { readLayerCoverage, readLayerManifest } from "@mailwoman/core/layers"
38
38
  import { expandH3Cell, shortCellToInt, type H3Cell, type H3CellShort } from "@mailwoman/spatial"
39
39
  import { cellToParent } from "h3-js"
40
- import { sql, type Kysely } from "kysely"
40
+ import { sql } from "kysely"
41
41
 
42
42
  import { BDC_COVERAGE_H3_RESOLUTION, BDC_H3_RESOLUTION, type BDCDatabase } from "../schema.ts"
43
43
 
@@ -139,14 +139,6 @@ const speedBucketCaseSQL = sql<string>`CASE
139
139
  ELSE ${BDC_SPEED_BUCKET_GIGABIT}
140
140
  END`
141
141
 
142
- /**
143
- * `BDCDatabase extends LayerContractDatabase` structurally, but Kysely's `transaction()` makes `Kysely<DB>` INVARIANT
144
- * in `DB` — same narrowing cast as `build-bdc.ts`'s `asContractDB`.
145
- */
146
- function asContractDB(kdb: DatabaseClient<BDCDatabase>): Kysely<LayerContractDatabase> {
147
- return kdb as unknown as Kysely<LayerContractDatabase>
148
- }
149
-
150
142
  /**
151
143
  * Reconstruct the res-6 ancestor of a res-9 short-cell int WITHOUT a centroid — see the module docstring for why the
152
144
  * centroid is the wrong input. Exported so tests can assert this agrees, cell-for-cell, with `build-bdc.ts`'s own
@@ -184,7 +176,7 @@ export async function filingLandscape(
184
176
 
185
177
  // Read (and validate) the manifest FIRST — a broken/missing manifest must throw before any block is
186
178
  // classified, never fall through to an "unstamped" answer (gate 4).
187
- const manifest = await readLayerManifest(asContractDB(db))
179
+ const manifest = await readLayerManifest(db)
188
180
 
189
181
  const requestedUnits: ReadonlyArray<string | number> = query.geoids ?? query.h3Cells!
190
182
  const unitColumn = query.geoids ? ("geoid" as const) : ("h3_cell" as const)
@@ -230,7 +222,7 @@ export async function filingLandscape(
230
222
  }
231
223
 
232
224
  const res6Parent = res9ShortCellToRes6Parent(candidateCell)
233
- const coverage = await readLayerCoverage(asContractDB(db), res6Parent)
225
+ const coverage = await readLayerCoverage(db, res6Parent)
234
226
 
235
227
  if (coverage === undefined) {
236
228
  unknownBlockCount++
package/sdk/parsing.ts CHANGED
@@ -2,45 +2,84 @@
2
2
  * @copyright Sister Software.
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
- * @file FCC BDC availability CSV byte-level parser.
6
- *
7
- * Re-homed from Nexus's `sync/fcc/bdc/parsing.ts` (relicense-by-copy, no provenance headers): the same
8
- * byte-scanning generator over the FCC's 12-column availability CSV see the Nexus `RawBSLAvailabilityRow`
9
- * tuple (`sync/fcc/bdc/block-aggregator.ts`) for the column order this scan assumes: frn, provider_id,
10
- * brand_name, location_id, technology, max_advertised_download_speed, max_advertised_upload_speed,
11
- * low_latency, business_residential_code, state_usps, block_geoid, h3_res8_id. Changed only where the
12
- * pre-registered 2a decisions require: `geoid` (column 10, `block_geoid`) decodes to an ASCII string
13
- * instead of staying a raw `Uint8Array` slice (decision 3), and `location_id` (column 3) stays a string
14
- * instead of `parseInt`ing it, preserving leading zeros (decision 1). `business_residential_code` is
15
- * likewise decoded to its ASCII string here the Nexus original kept only the field's first raw byte as
16
- * a bare `number`, which this port's `BDCAvailabilityRow` shape doesn't call for.
17
- *
18
- * Columns 0-2 (frn, provider_id, brand_name) and 9, 11 (state_usps, h3_res8_id) are scanned over but
19
- * never sliced into the output record: `provider_id` comes from the `providerID` parameter instead (the
20
- * FCC partitions availability files per provider, so the caller already knows it), and FRN/brand/state/H3
21
- * join concerns are out of scope for 2a (decision 8 provider identity is a 2c registry-join seam).
5
+ * @file FCC BDC availability CSV row reader.
6
+ *
7
+ * Streams the FCC's 12-column availability CSV through `CSVSpliterator` in `array` mode and projects the
8
+ * seven columns 2a keeps. Columns 0-2 (`frn`, `provider_id`, `brand_name`) and 9, 11 (`state_usps`,
9
+ * `h3_res8_id`) are read past and never emitted: `provider_id` comes from the {@linkcode ProviderID}
10
+ * parameter instead (the FCC partitions availability files per provider, so the caller already knows it),
11
+ * and FRN/brand/state/H3 join concerns are a 2c registry-join seam.
12
+ *
13
+ * Two projection decisions are load-bearing and pre-registered. `location_id` (column 3) stays a STRING —
14
+ * the FCC's values are zero-padded 10-digit strings and `parseInt` would lose the leading zeros (decision
15
+ * 1). `geoid` (column 10) is a string joining `TIGERBlockTable.GEOID` (decision 3).
16
+ *
17
+ * ## Why the source is a resource, not a Buffer
18
+ *
19
+ * This read is STREAMING because the files do not fit the alternative. One state × one technology —
20
+ * `bdc_48_FibertothePremises_fixed_broadband_D25` is 920 MB and 10,369,043 rows, and a national run
21
+ * spans every state × every technology. The previous byte scanner took a `Buffer`, so its caller opened
22
+ * with `readFile(csvPath)` and held the whole file resident per file.
23
+ *
24
+ * ## Why quoting is not optional here
25
+ *
26
+ * Measured on that same file: 421,555 rows carry one embedded comma inside a quoted `brand_name` and 327
27
+ * carry two ("FiberFirst, LLC", "Valor Telecommunications of Texas, LP"). A delimiter scan blind to quotes
28
+ * shifts every column right of `brand_name` on 4% of rows — measured exactly, a quote-blind
29
+ * `String.split(",")` mismatches 81,095 of 2,000,000 real rows. `enableQuoteHandling` also keeps an
30
+ * embedded NEWLINE inside its row — no row in that file needs it (the 12/13/14-field line counts sum
31
+ * exactly to `wc -l`, so no record is split across lines), but the guarantee is what makes the reader safe
32
+ * on a file nobody has measured yet.
33
+ *
34
+ * ## What this costs
35
+ *
36
+ * Measured on the full 920 MB / 10,369,042-row file, projecting these 7 of 12 columns, identical checksums
37
+ * across every arm. One arm per process — arms sharing a process mis-rank, because the JIT warms across
38
+ * them:
39
+ *
40
+ * ```
41
+ * hand-rolled byte scan, whole buffer 778 ns/row peak RSS 978 MB
42
+ * spliterator 6.2.0, streaming 2,435 ns/row peak RSS 112 MB
43
+ * spliterator patched, streaming 860 ns/row peak RSS 100 MB
44
+ * ```
45
+ *
46
+ * The 6.2.0 row is why this looked like a regression when it landed: CSVSpliterator decoded once per
47
+ * COLUMN, and `TextDecoder`'s per-call overhead dominates at column sizes. Fixed upstream in
48
+ * sister-software/spliterator#6 by decoding the row once — so the streaming path now runs within 10% of a
49
+ * whole-buffer byte scan while holding 9.8x less memory. **Requires a spliterator release carrying that
50
+ * fix**; on 6.2.0 this reader is correct and roughly 3x slower.
51
+ *
52
+ * Do not route around it by hand-rolling a splitter here again. The staging insert this feeds measures 615
53
+ * ns/row, so the parse does sit on the critical path — but a local fast-and-quote-blind parser is exactly
54
+ * the trade that produced the scanner this replaced, and the quote-blind version is 4% wrong.
22
55
  */
23
56
 
57
+ import type { AsyncDataResource } from "spliterator"
58
+ import { CSVSpliterator } from "spliterator"
59
+
24
60
  import type { ProviderID } from "./common.ts"
25
61
 
26
62
  /**
27
- * Byte values this scanner switches on.
63
+ * Column positions in the FCC's 12-column availability CSV. Named rather than sliced by offset so a reader can check
64
+ * them against the header row without counting commas:
28
65
  *
29
- * Local to this file — no generic newline-delimited-file utility exists elsewhere in the repo to import. Nexus's
30
- * equivalent (`LineDelimitedCharacter`) lived in `@isp.nexus/sdk/files`, a workspace this port doesn't carry over, and
31
- * repo convention forbids TS `enum` anyway (`erasableSyntaxOnly`).
66
+ * `frn,provider_id,brand_name,location_id,technology,max_advertised_download_speed,`
67
+ * `max_advertised_upload_speed,low_latency,business_residential_code,state_usps,block_geoid,h3_res8_id`
32
68
  */
33
- const CSVByte = {
34
- Newline: 10,
35
- Comma: 44,
36
- DoubleQuote: 34,
37
- One: 49,
69
+ const Column = {
70
+ LocationID: 3,
71
+ Technology: 4,
72
+ MaxAdvertisedDownloadSpeed: 5,
73
+ MaxAdvertisedUploadSpeed: 6,
74
+ LowLatency: 7,
75
+ BusinessResidentialCode: 8,
76
+ BlockGeoID: 10,
38
77
  } as const
39
78
 
40
79
  /**
41
80
  * A single parsed row of FCC BDC availability data.
42
81
  *
43
- * @see {@linkcode takeAvailabilityLine}
82
+ * @see {@linkcode readAvailabilityRows}
44
83
  */
45
84
  export interface BDCAvailabilityRow {
46
85
  provider_id: number
@@ -55,70 +94,64 @@ export interface BDCAvailabilityRow {
55
94
  low_latency: 0 | 1
56
95
  business_residential_code: string
57
96
  /**
58
- * Decoded to an ASCII string at the parse boundary (2a decision 3) — joins `TIGERBlockTable.GEOID` (note: uppercase
59
- * column on that side).
97
+ * Joins `TIGERBlockTable.GEOID` (note: uppercase column on that side) 2a decision 3.
60
98
  */
61
99
  geoid: string
62
100
  }
63
101
 
64
102
  /**
65
- * Given a buffer containing FCC BDC availability CSV data, yield each data row (the header row is skipped) as a
66
- * {@linkcode BDCAvailabilityRow}.
103
+ * Project one already-split CSV row onto {@linkcode BDCAvailabilityRow}.
67
104
  *
68
- * A byte-level scan, not a general CSV parser: it tracks comma/newline byte positions directly and only toggles
69
- * quote-awareness via a running double-quote count (an odd count means the scanner is currently inside a quoted field,
70
- * so a comma there isn't a column delimiter) — matching the Nexus original's approach for this specific, known-shaped
71
- * 12-column file rather than reaching for a general CSV library.
105
+ * Separate from the iteration so the sync and async readers cannot drift in what they emit — the one-function
106
+ * discipline. Not exported: a caller with a split row wants {@linkcode readAvailabilityRowsSync}.
72
107
  */
73
- export function* takeAvailabilityLine(csvBuffer: Buffer, providerID: ProviderID): Iterable<BDCAvailabilityRow> {
74
- // Skip the header row.
75
- let byteIndex = csvBuffer.indexOf(CSVByte.Newline) + 1
76
- const contentDelimiters = new Uint32Array(12) // 12 columns
77
- contentDelimiters[0] = byteIndex
78
- let delimiterIndex = 1
79
- let doubleQuoteCount = 0
80
-
81
- while (byteIndex < csvBuffer.length) {
82
- const byte = csvBuffer[byteIndex]
83
-
84
- if (byte === CSVByte.DoubleQuote) {
85
- doubleQuoteCount++
86
- }
87
-
88
- if (byte === CSVByte.Comma && doubleQuoteCount % 2 === 0) {
89
- contentDelimiters[delimiterIndex] = byteIndex
90
-
91
- delimiterIndex++
92
- }
93
-
94
- if (byte === CSVByte.Newline) {
95
- contentDelimiters[delimiterIndex] = CSVByte.Newline
96
- const slices: Buffer[] = []
97
-
98
- // Skip columns 0-2 (frn, provider_id, brand_name) — see the file header for why.
99
- for (let i = 3; i < contentDelimiters.length; i++) {
100
- const start = contentDelimiters[i]! + 1
101
- const end = contentDelimiters[i + 1]
102
-
103
- slices.push(csvBuffer.subarray(start, end))
104
- }
108
+ function projectRow(columns: readonly string[], providerID: ProviderID): BDCAvailabilityRow {
109
+ return {
110
+ provider_id: providerID,
111
+ location_id: columns[Column.LocationID] ?? "",
112
+ technology_code: Number.parseInt(columns[Column.Technology] ?? "", 10),
113
+ max_advertised_download_speed: Number.parseInt(columns[Column.MaxAdvertisedDownloadSpeed] ?? "", 10),
114
+ max_advertised_upload_speed: Number.parseInt(columns[Column.MaxAdvertisedUploadSpeed] ?? "", 10),
115
+ low_latency: columns[Column.LowLatency] === "1" ? 1 : 0,
116
+ business_residential_code: columns[Column.BusinessResidentialCode] ?? "",
117
+ geoid: columns[Column.BlockGeoID] ?? "",
118
+ }
119
+ }
105
120
 
106
- const record: BDCAvailabilityRow = {
107
- provider_id: providerID,
108
- location_id: slices[0]!.toString("ascii"),
109
- technology_code: Number.parseInt(slices[1]!.toString(), 10),
110
- max_advertised_download_speed: Number.parseInt(slices[2]!.toString(), 10),
111
- max_advertised_upload_speed: Number.parseInt(slices[3]!.toString(), 10),
112
- low_latency: slices[4]![0] === CSVByte.One ? 1 : 0,
113
- business_residential_code: slices[5]!.toString("ascii"),
114
- geoid: slices[7]!.toString("ascii"),
115
- }
121
+ /**
122
+ * Shared reader options. `header: true` consumes the first row as the header even in `array` mode;
123
+ * `enableQuoteHandling` is what makes the 421,882 quoted-brand rows keep their column alignment. `crlf` already
124
+ * defaults to `true` for CSV (RFC 4180), so a CRLF file does not leak `\r` into the last column.
125
+ */
126
+ const READER_OPTIONS = { mode: "array", enableQuoteHandling: true } as const
116
127
 
117
- yield record
118
- delimiterIndex = 1
119
- doubleQuoteCount = 0
120
- }
128
+ /**
129
+ * Stream an FCC BDC availability CSV, yielding every data row. The header row is consumed, never emitted.
130
+ *
131
+ * `source` is anything `spliterator` can open asynchronously — a path string, a `path-ts` builder, a URL, a file
132
+ * handle, or an async chunk iterator. Prefer handing it the path and letting it own the read: that is what keeps a 920
133
+ * MB file off the heap.
134
+ */
135
+ export async function* readAvailabilityRows(
136
+ source: AsyncDataResource,
137
+ providerID: ProviderID
138
+ ): AsyncIterable<BDCAvailabilityRow> {
139
+ for await (const columns of CSVSpliterator.fromAsync<string[]>(source, READER_OPTIONS)) {
140
+ yield projectRow(columns, providerID)
141
+ }
142
+ }
121
143
 
122
- byteIndex++
144
+ /**
145
+ * Synchronous sibling for an in-memory buffer — fixtures and tests, never the build path.
146
+ *
147
+ * Kept because a test that must assert on a literal CSV should not have to stand up a stream to do it. If you are
148
+ * reaching for this against a file on disk, reach for {@linkcode readAvailabilityRows} instead.
149
+ */
150
+ export function* readAvailabilityRowsSync(
151
+ csvBuffer: Buffer | string,
152
+ providerID: ProviderID
153
+ ): Iterable<BDCAvailabilityRow> {
154
+ for (const columns of CSVSpliterator.from<string[]>(csvBuffer, READER_OPTIONS)) {
155
+ yield projectRow(columns, providerID)
123
156
  }
124
157
  }
@@ -113,11 +113,15 @@
113
113
  */
114
114
 
115
115
  import type { DatabaseClient } from "@mailwoman/core/kysley/client"
116
- import { readLayerCoverage, readLayerManifest, type LayerContractDatabase } from "@mailwoman/core/layers"
116
+ import {
117
+ readLayerCoverage,
118
+ readLayerManifest,
119
+ type LayerContractDatabase,
120
+ type LayerContractHandle,
121
+ } from "@mailwoman/core/layers"
117
122
  import type { POILookup } from "@mailwoman/resolver-wof-sqlite/poi-lookup"
118
123
  import { shortCellToInt, type H3Cell, type PointLiteral } from "@mailwoman/spatial"
119
124
  import { latLngToCell } from "h3-js"
120
- import type { Kysely } from "kysely"
121
125
 
122
126
  import { BDC_H3_RESOLUTION, type BDCDatabase } from "../schema.ts"
123
127
  import {
@@ -295,15 +299,6 @@ const SPEED_BUCKET_RANK: Readonly<Record<string, number>> = {
295
299
  [BDC_SPEED_BUCKET_GIGABIT]: 3,
296
300
  }
297
301
 
298
- /**
299
- * `BDCDatabase extends LayerContractDatabase` structurally, but Kysely's `transaction()` makes `Kysely<DB>` INVARIANT
300
- * in `DB` — same cast idiom as `filing-landscape.ts`'s own private `asContractDB` (decision 8: reuse, never re-derive;
301
- * copied rather than imported since the original is module-private).
302
- */
303
- function asContractDB(kdb: DatabaseClient<BDCDatabase>): Kysely<LayerContractDatabase> {
304
- return kdb as unknown as Kysely<LayerContractDatabase>
305
- }
306
-
307
302
  /**
308
303
  * `true` when `filing` corroborates the claim: same `technology_code`, AND `filing.speed_bucket` ranks at or above the
309
304
  * claimed download speed's own bucket. A different tech, or a same-tech but LESSER filing, is `false` — never disproof,
@@ -373,7 +368,7 @@ function combineCoverage(
373
368
  */
374
369
  async function assertLayerSpineResolution(
375
370
  layer: "bdc" | "poi",
376
- contractDB: Kysely<LayerContractDatabase>,
371
+ contractDB: LayerContractHandle,
377
372
  expectedResolution: number
378
373
  ): Promise<void> {
379
374
  const manifest = await readLayerManifest(contractDB)
@@ -430,7 +425,7 @@ export async function plausibilityCheck(claim: PlausibilityClaim, deps: Plausibi
430
425
  // independently per WIRED layer, not only when both are present: a poi-only call still joins poi's coverage
431
426
  // table against a BDC_H3_RESOLUTION-derived cell (below) and must not do so unchecked.
432
427
  if (deps.bdcDB) {
433
- await assertLayerSpineResolution("bdc", asContractDB(deps.bdcDB), BDC_H3_RESOLUTION)
428
+ await assertLayerSpineResolution("bdc", deps.bdcDB, BDC_H3_RESOLUTION)
434
429
  }
435
430
 
436
431
  if (deps.poi) {