@mailwoman/tiger 8.5.0 → 9.0.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/out/sdk/download.d.ts +32 -0
- package/out/sdk/download.d.ts.map +1 -0
- package/out/sdk/download.js +64 -0
- package/out/sdk/download.js.map +1 -0
- package/out/sdk/fetch.d.ts.map +1 -1
- package/out/sdk/fetch.js +6 -37
- package/out/sdk/fetch.js.map +1 -1
- package/out/sdk/redistricting.d.ts.map +1 -1
- package/out/sdk/redistricting.js +1 -29
- package/out/sdk/redistricting.js.map +1 -1
- package/out/tools/race-dots.d.ts.map +1 -1
- package/out/tools/race-dots.js +3 -6
- package/out/tools/race-dots.js.map +1 -1
- package/out/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/sdk/download.ts +71 -0
- package/sdk/fetch.ts +7 -44
- package/sdk/redistricting.ts +1 -36
- package/tools/race-dots.ts +3 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/tiger",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.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": "
|
|
77
|
-
"@mailwoman/spatial": "
|
|
76
|
+
"@mailwoman/core": "9.0.0",
|
|
77
|
+
"@mailwoman/spatial": "9.0.0",
|
|
78
78
|
"@turf/boolean-contains": "^7.3.5",
|
|
79
79
|
"kysely": "^0.29.4",
|
|
80
80
|
"shapefile-parser": "^1.0.3",
|
|
81
|
-
"spliterator": "^
|
|
81
|
+
"spliterator": "^6.0.1",
|
|
82
82
|
"type-fest": "^5.8.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
package/sdk/download.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
/**
|
|
24
|
+
* Run a command and resolve its stdout, rejecting with the captured stderr on a non-zero exit.
|
|
25
|
+
*
|
|
26
|
+
* Used for the `unzip -tq` integrity probe below — a shelled-out check rather than a library one because the archives
|
|
27
|
+
* are large and `unzip -t` streams them without materializing the contents.
|
|
28
|
+
*/
|
|
29
|
+
export function runCapture(cmd: string, args: string[]): Promise<string> {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const child = spawn(cmd, args)
|
|
32
|
+
let out = ""
|
|
33
|
+
let err = ""
|
|
34
|
+
child.stdout.on("data", (d) => (out += d))
|
|
35
|
+
child.stderr.on("data", (d) => (err += d))
|
|
36
|
+
child.on("error", reject)
|
|
37
|
+
|
|
38
|
+
child.on("close", (code) =>
|
|
39
|
+
code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Download `url` to `dest` unless a VALID copy is already there. Returns `true` when the cache was reused, `false` when
|
|
46
|
+
* a download happened.
|
|
47
|
+
*
|
|
48
|
+
* "Valid" means `unzip -tq` passes — an existence check alone is not enough, because an interrupted download leaves a
|
|
49
|
+
* plausible-looking file that fails only much later, inside ogr2ogr. Writes to a `.tmp` sibling and renames, so `dest`
|
|
50
|
+
* is never a partial archive.
|
|
51
|
+
*/
|
|
52
|
+
export async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
|
|
53
|
+
if (existsSync(dest)) {
|
|
54
|
+
try {
|
|
55
|
+
await runCapture("unzip", ["-tq", dest])
|
|
56
|
+
|
|
57
|
+
return true
|
|
58
|
+
} catch {
|
|
59
|
+
// corrupt cache — re-download
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tmp = dest + ".tmp"
|
|
64
|
+
const res = await fetch(url, { redirect: "follow" })
|
|
65
|
+
|
|
66
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
|
|
67
|
+
await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
|
|
68
|
+
await rename(tmp, dest)
|
|
69
|
+
|
|
70
|
+
return false
|
|
71
|
+
}
|
package/sdk/fetch.ts
CHANGED
|
@@ -33,9 +33,11 @@ import { Readable } from "node:stream"
|
|
|
33
33
|
import { pipeline } from "node:stream/promises"
|
|
34
34
|
|
|
35
35
|
import { DatabaseClient } from "@mailwoman/core/kysley/client"
|
|
36
|
+
import { tryParsingJSON } from "@mailwoman/core/objects"
|
|
36
37
|
import { mailwomanDataRoot } from "@mailwoman/core/utils"
|
|
37
38
|
import { TextSpliterator } from "spliterator"
|
|
38
39
|
|
|
40
|
+
import { downloadIfNeeded, runCapture } from "./download.ts"
|
|
39
41
|
import type { TIGERBlockTable, TIGERDatabase, TIGERPlaceTable, TIGERStreetTable } from "./schema.ts"
|
|
40
42
|
import { initializeTIGERSchema, TIGER_PRAGMAS } from "./schema.ts"
|
|
41
43
|
|
|
@@ -169,42 +171,6 @@ function buildRow(level: TIGERFetchLevel, p: Record<string, unknown>, geometry:
|
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
|
|
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
174
|
/**
|
|
209
175
|
* Scrape the ADDRFEAT directory listing for a state's county FIPS codes.
|
|
210
176
|
*/
|
|
@@ -331,17 +297,14 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
|
|
|
331
297
|
child.on("close", (code) => resolve(code ?? 0))
|
|
332
298
|
})
|
|
333
299
|
|
|
334
|
-
// GeoJSONSeq is line-delimited;
|
|
335
|
-
//
|
|
300
|
+
// GeoJSONSeq is line-delimited; per-line `tryParsingJSON` so a malformed record is
|
|
301
|
+
// tolerated (skipped), not thrown.
|
|
336
302
|
for await (const line of TextSpliterator.fromAsync(child.stdout)) {
|
|
337
303
|
if (!line) continue
|
|
338
|
-
let feat: { properties?: Record<string, unknown>; geometry?: unknown }
|
|
339
304
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
continue
|
|
344
|
-
}
|
|
305
|
+
const feat = tryParsingJSON<{ properties?: Record<string, unknown>; geometry?: unknown }>(line)
|
|
306
|
+
|
|
307
|
+
if (!feat) continue
|
|
345
308
|
|
|
346
309
|
if (!feat.properties) continue
|
|
347
310
|
|
package/sdk/redistricting.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { mailwomanDataRoot } from "@mailwoman/core/utils"
|
|
|
29
29
|
import { TextSpliterator } from "spliterator"
|
|
30
30
|
|
|
31
31
|
import { AdminLevel1CodeToAbbreviation, StateName, type AdminLevel1Code } from "../state.ts"
|
|
32
|
+
import { downloadIfNeeded, runCapture } from "./download.ts"
|
|
32
33
|
import { initializeTIGERSchema, TIGER_PRAGMAS, type PLBlockTable, type TIGERDatabase } from "./schema.ts"
|
|
33
34
|
|
|
34
35
|
const REDISTRICTING_BASE =
|
|
@@ -104,42 +105,6 @@ export interface FetchRedistrictingResult {
|
|
|
104
105
|
inserted: number
|
|
105
106
|
}
|
|
106
107
|
|
|
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
108
|
// Pipe-delimited, fixed field-offset census rows — the manual `split("|")` at each call site stays (the
|
|
144
109
|
// parse indexes by position, not by header). spliterator keeps CRLF's trailing CR where readline stripped
|
|
145
110
|
// it, but every field this parser reads (geo GEOCODE/LOGRECNO ≤ 9, segment-1 LOGRECNO + P2 ≤ 86) sits well
|
package/tools/race-dots.ts
CHANGED
|
@@ -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
|
-
|
|
172
|
+
const geom = tryParsingJSON<{ type: string; coordinates: unknown }>(row.geometry)
|
|
172
173
|
|
|
173
|
-
|
|
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[])
|