@mailwoman/tiger 8.6.0 → 9.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailwoman/tiger",
3
- "version": "8.6.0",
3
+ "version": "9.1.0",
4
4
  "description": "US Census TIGER/Line data processing and analysis.",
5
5
  "keywords": [
6
6
  "census",
@@ -73,12 +73,12 @@
73
73
  "test": "node ./out/sdk/TIGERService.test.js"
74
74
  },
75
75
  "dependencies": {
76
- "@mailwoman/core": "8.6.0",
77
- "@mailwoman/spatial": "8.6.0",
76
+ "@mailwoman/core": "9.1.0",
77
+ "@mailwoman/spatial": "9.1.0",
78
78
  "@turf/boolean-contains": "^7.3.5",
79
79
  "kysely": "^0.29.4",
80
80
  "shapefile-parser": "^1.0.3",
81
- "spliterator": "^4.0.0",
81
+ "spliterator": "^6.1.0",
82
82
  "type-fest": "^5.8.0"
83
83
  },
84
84
  "devDependencies": {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Cached archive download + subprocess capture, shared by `fetch.ts` and `redistricting.ts`.
7
+ *
8
+ * RAW `fetch` HERE IS DELIBERATE. `AGENTS.md` requires HTTP clients to extend or instantiate
9
+ * `APIClient`, and that rule is about API REQUESTS — small bodies, repeated calls, rate-limited
10
+ * hosts, where its pacing, bounded retry, response caching and `ResourceError` mapping all earn
11
+ * their keep. This is a multi-gigabyte file transfer streamed straight to disk. Response caching
12
+ * would be nonsense at that size, pacing has nothing to pace (one request), and axios buffers a
13
+ * non-stream response type in memory. The primitive that fits a body this large is the one that
14
+ * never holds it: a web stream piped to a write stream.
15
+ */
16
+
17
+ import { spawn } from "node:child_process"
18
+ import { createWriteStream, existsSync } from "node:fs"
19
+ import { rename } from "node:fs/promises"
20
+ import { Readable } from "node:stream"
21
+ import { pipeline } from "node:stream/promises"
22
+
23
+ import { verifyZipIntegrity } from "@mailwoman/core/fs/zip"
24
+
25
+ /**
26
+ * Run a command and resolve its stdout, rejecting with the captured stderr on a non-zero exit.
27
+ *
28
+ * Used for the `unzip -tq` integrity probe below — a shelled-out check rather than a library one because the archives
29
+ * are large and `unzip -t` streams them without materializing the contents.
30
+ */
31
+ export function runCapture(cmd: string, args: string[]): Promise<string> {
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawn(cmd, args)
34
+ let out = ""
35
+ let err = ""
36
+ child.stdout.on("data", (d) => (out += d))
37
+ child.stderr.on("data", (d) => (err += d))
38
+ child.on("error", reject)
39
+
40
+ child.on("close", (code) =>
41
+ code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
42
+ )
43
+ })
44
+ }
45
+
46
+ /**
47
+ * Download `url` to `dest` unless a VALID copy is already there. Returns `true` when the cache was reused, `false` when
48
+ * a download happened.
49
+ *
50
+ * "Valid" means every member's CRC-32 checks out — an existence check alone is not enough, because an interrupted
51
+ * download leaves a plausible-looking file that fails only much later, inside ogr2ogr. Writes to a `.tmp` sibling and
52
+ * renames, so `dest` is never a partial archive.
53
+ */
54
+ export async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
55
+ if (existsSync(dest)) {
56
+ try {
57
+ await verifyZipIntegrity(dest)
58
+
59
+ return true
60
+ } catch {
61
+ // corrupt cache — re-download
62
+ }
63
+ }
64
+
65
+ const tmp = dest + ".tmp"
66
+ const res = await fetch(url, { redirect: "follow" })
67
+
68
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
69
+ await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
70
+ await rename(tmp, dest)
71
+
72
+ return false
73
+ }
package/sdk/fetch.ts CHANGED
@@ -32,10 +32,13 @@ import { DatabaseSync } from "node:sqlite"
32
32
  import { Readable } from "node:stream"
33
33
  import { pipeline } from "node:stream/promises"
34
34
 
35
+ import { extractZipEntries } from "@mailwoman/core/fs/zip"
35
36
  import { DatabaseClient } from "@mailwoman/core/kysley/client"
37
+ import { tryParsingJSON } from "@mailwoman/core/objects"
36
38
  import { mailwomanDataRoot } from "@mailwoman/core/utils"
37
39
  import { TextSpliterator } from "spliterator"
38
40
 
41
+ import { downloadIfNeeded } from "./download.ts"
39
42
  import type { TIGERBlockTable, TIGERDatabase, TIGERPlaceTable, TIGERStreetTable } from "./schema.ts"
40
43
  import { initializeTIGERSchema, TIGER_PRAGMAS } from "./schema.ts"
41
44
 
@@ -169,42 +172,6 @@ function buildRow(level: TIGERFetchLevel, p: Record<string, unknown>, geometry:
169
172
  }
170
173
  }
171
174
 
172
- function runCapture(cmd: string, args: string[]): Promise<string> {
173
- return new Promise((resolve, reject) => {
174
- const child = spawn(cmd, args)
175
- let out = ""
176
- let err = ""
177
- child.stdout.on("data", (d) => (out += d))
178
- child.stderr.on("data", (d) => (err += d))
179
- child.on("error", reject)
180
-
181
- child.on("close", (code) =>
182
- code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
183
- )
184
- })
185
- }
186
-
187
- async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
188
- if (existsSync(dest)) {
189
- try {
190
- await runCapture("unzip", ["-tq", dest])
191
-
192
- return true
193
- } catch {
194
- // corrupt cache — re-download
195
- }
196
- }
197
-
198
- const tmp = dest + ".tmp"
199
- const res = await fetch(url, { redirect: "follow" })
200
-
201
- if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
202
- await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
203
- await rename(tmp, dest)
204
-
205
- return false
206
- }
207
-
208
175
  /**
209
176
  * Scrape the ADDRFEAT directory listing for a state's county FIPS codes.
210
177
  */
@@ -304,7 +271,7 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
304
271
  const cached = await downloadIfNeeded(url, zipPath)
305
272
  yield { phase: "download", file: zipName, cached }
306
273
 
307
- await runCapture("unzip", ["-o", "-q", zipPath, "-d", cacheDir])
274
+ await extractZipEntries(zipPath, cacheDir)
308
275
  const layer = `tl_${vintage}_${unit}_${level}`
309
276
  const shpPath = join(cacheDir, layer + ".shp")
310
277
  yield { phase: "extract", file: layer + ".shp" }
@@ -331,17 +298,14 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
331
298
  child.on("close", (code) => resolve(code ?? 0))
332
299
  })
333
300
 
334
- // GeoJSONSeq is line-delimited; keep the per-line `JSON.parse` in a try/catch so a malformed
335
- // record is tolerated (skipped), not thrown.
301
+ // GeoJSONSeq is line-delimited; per-line `tryParsingJSON` so a malformed record is
302
+ // tolerated (skipped), not thrown.
336
303
  for await (const line of TextSpliterator.fromAsync(child.stdout)) {
337
304
  if (!line) continue
338
- let feat: { properties?: Record<string, unknown>; geometry?: unknown }
339
305
 
340
- try {
341
- feat = JSON.parse(line)
342
- } catch {
343
- continue
344
- }
306
+ const feat = tryParsingJSON<{ properties?: Record<string, unknown>; geometry?: unknown }>(line)
307
+
308
+ if (!feat) continue
345
309
 
346
310
  if (!feat.properties) continue
347
311
 
@@ -24,11 +24,13 @@ import { DatabaseSync } from "node:sqlite"
24
24
  import { Readable } from "node:stream"
25
25
  import { pipeline } from "node:stream/promises"
26
26
 
27
+ import { extractZipEntries } from "@mailwoman/core/fs/zip"
27
28
  import { DatabaseClient } from "@mailwoman/core/kysley/client"
28
29
  import { mailwomanDataRoot } from "@mailwoman/core/utils"
29
30
  import { TextSpliterator } from "spliterator"
30
31
 
31
32
  import { AdminLevel1CodeToAbbreviation, StateName, type AdminLevel1Code } from "../state.ts"
33
+ import { downloadIfNeeded } from "./download.ts"
32
34
  import { initializeTIGERSchema, TIGER_PRAGMAS, type PLBlockTable, type TIGERDatabase } from "./schema.ts"
33
35
 
34
36
  const REDISTRICTING_BASE =
@@ -104,42 +106,6 @@ export interface FetchRedistrictingResult {
104
106
  inserted: number
105
107
  }
106
108
 
107
- function runCapture(cmd: string, args: string[]): Promise<string> {
108
- return new Promise((resolve, reject) => {
109
- const child = spawn(cmd, args)
110
- let out = ""
111
- let err = ""
112
- child.stdout.on("data", (d) => (out += d))
113
- child.stderr.on("data", (d) => (err += d))
114
- child.on("error", reject)
115
-
116
- child.on("close", (code) =>
117
- code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
118
- )
119
- })
120
- }
121
-
122
- async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
123
- if (existsSync(dest)) {
124
- try {
125
- await runCapture("unzip", ["-tq", dest])
126
-
127
- return true
128
- } catch {
129
- // corrupt cache — re-download
130
- }
131
- }
132
-
133
- const tmp = dest + ".tmp"
134
- const res = await fetch(url, { redirect: "follow" })
135
-
136
- if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
137
- await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
138
- await rename(tmp, dest)
139
-
140
- return false
141
- }
142
-
143
109
  // Pipe-delimited, fixed field-offset census rows — the manual `split("|")` at each call site stays (the
144
110
  // parse indexes by position, not by header). spliterator keeps CRLF's trailing CR where readline stripped
145
111
  // it, but every field this parser reads (geo GEOCODE/LOGRECNO ≤ 9, segment-1 LOGRECNO + P2 ≤ 86) sits well
@@ -183,7 +149,7 @@ export async function* fetchRedistricting(
183
149
  const cached = await downloadIfNeeded(url, zipPath)
184
150
  yield { phase: "download", file: zipName, cached }
185
151
 
186
- await runCapture("unzip", ["-o", "-q", zipPath, "-d", cacheDir])
152
+ await extractZipEntries(zipPath, cacheDir)
187
153
  const geoPath = join(cacheDir, `${fileAbbr}geo${vintage}.pl`)
188
154
  const seg1Path = join(cacheDir, `${fileAbbr}00001${vintage}.pl`)
189
155
  yield { phase: "extract", file: `${fileAbbr}geo${vintage}.pl` }
@@ -24,6 +24,7 @@
24
24
  import { createWriteStream } from "node:fs"
25
25
  import { DatabaseSync } from "node:sqlite"
26
26
 
27
+ import { tryParsingJSON } from "@mailwoman/core/objects"
27
28
  import { dataRootPath } from "@mailwoman/core/utils"
28
29
 
29
30
  /**
@@ -168,13 +169,9 @@ export async function raceDots(
168
169
  skipped = 0
169
170
 
170
171
  for (const row of rows) {
171
- let geom: { type: string; coordinates: unknown }
172
+ const geom = tryParsingJSON<{ type: string; coordinates: unknown }>(row.geometry)
172
173
 
173
- try {
174
- geom = JSON.parse(row.geometry)
175
- } catch {
176
- continue
177
- }
174
+ if (!geom) continue
178
175
 
179
176
  const polys: PolygonCoords[] =
180
177
  geom.type === "Polygon" ? [geom.coordinates as PolygonCoords] : (geom.coordinates as PolygonCoords[])