@mailwoman/tiger 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.
Files changed (42) hide show
  1. package/out/sdk/download.d.ts +3 -10
  2. package/out/sdk/download.d.ts.map +1 -1
  3. package/out/sdk/download.js +5 -22
  4. package/out/sdk/download.js.map +1 -1
  5. package/out/sdk/fetch.d.ts.map +1 -1
  6. package/out/sdk/fetch.js +14 -10
  7. package/out/sdk/fetch.js.map +1 -1
  8. package/out/sdk/redistricting.d.ts.map +1 -1
  9. package/out/sdk/redistricting.js +4 -7
  10. package/out/sdk/redistricting.js.map +1 -1
  11. package/out/sdk/state/TIGERTabulatedBlockEntity.d.ts.map +1 -1
  12. package/out/sdk/state/TIGERTabulatedBlockEntity.js +1 -1
  13. package/out/sdk/state/TIGERTabulatedBlockEntity.js.map +1 -1
  14. package/out/sdk/state/TIGERTractEntity.d.ts +1 -1
  15. package/out/sdk/state/TIGERTractEntity.d.ts.map +1 -1
  16. package/out/sdk/state/block-operations.d.ts +1 -1
  17. package/out/sdk/state/block-operations.d.ts.map +1 -1
  18. package/out/sdk/state/block-operations.js.map +1 -1
  19. package/out/tools/index.d.ts +1 -5
  20. package/out/tools/index.d.ts.map +1 -1
  21. package/out/tools/index.js +1 -5
  22. package/out/tools/index.js.map +1 -1
  23. package/out/tools/race-dots-map.d.ts.map +1 -1
  24. package/out/tools/race-dots-map.js +2 -1
  25. package/out/tools/race-dots-map.js.map +1 -1
  26. package/out/tools/race-dots.js +2 -2
  27. package/out/tools/race-dots.js.map +1 -1
  28. package/out/tools/serve-range.d.ts.map +1 -1
  29. package/out/tools/serve-range.js +2 -1
  30. package/out/tools/serve-range.js.map +1 -1
  31. package/out/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +19 -7
  33. package/sdk/download.ts +5 -25
  34. package/sdk/fetch.ts +14 -9
  35. package/sdk/redistricting.ts +4 -7
  36. package/sdk/state/TIGERTabulatedBlockEntity.ts +2 -1
  37. package/sdk/state/TIGERTractEntity.ts +1 -1
  38. package/sdk/state/block-operations.ts +2 -1
  39. package/tools/index.ts +1 -5
  40. package/tools/race-dots-map.ts +3 -1
  41. package/tools/race-dots.ts +2 -2
  42. package/tools/serve-range.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailwoman/tiger",
3
- "version": "9.0.0",
3
+ "version": "9.2.0",
4
4
  "description": "US Census TIGER/Line data processing and analysis.",
5
5
  "keywords": [
6
6
  "census",
@@ -21,7 +21,7 @@
21
21
  "repository": {
22
22
  "type": "git",
23
23
  "url": "https://github.com/sister-software/mailwoman.git",
24
- "directory": "tiger"
24
+ "directory": "packages/tiger"
25
25
  },
26
26
  "files": [
27
27
  "out/",
@@ -33,9 +33,16 @@
33
33
  "!*.test.tsx",
34
34
  "!**/*.test.ts",
35
35
  "!**/*.test.tsx",
36
- "!__snapshots__/"
36
+ "!__snapshots__/",
37
+ "!test/**"
37
38
  ],
38
39
  "type": "module",
40
+ "imports": {
41
+ "#index": {
42
+ "types": "./out/index.d.ts",
43
+ "default": "./out/index.js"
44
+ }
45
+ },
39
46
  "exports": {
40
47
  "./package.json": "./package.json",
41
48
  "./sdk": {
@@ -67,18 +74,23 @@
67
74
  "types": "./out/index.d.ts",
68
75
  "default": "./out/index.js"
69
76
  }
77
+ },
78
+ "imports": {
79
+ "#index": {
80
+ "types": "./out/index.d.ts",
81
+ "default": "./out/index.js"
82
+ }
70
83
  }
71
84
  },
72
85
  "scripts": {
73
86
  "test": "node ./out/sdk/TIGERService.test.js"
74
87
  },
75
88
  "dependencies": {
76
- "@mailwoman/core": "9.0.0",
77
- "@mailwoman/spatial": "9.0.0",
89
+ "@mailwoman/core": "9.2.0",
90
+ "@mailwoman/spatial": "9.2.0",
78
91
  "@turf/boolean-contains": "^7.3.5",
79
92
  "kysely": "^0.29.4",
80
- "shapefile-parser": "^1.0.3",
81
- "spliterator": "^6.0.1",
93
+ "spliterator": "^6.2.0",
82
94
  "type-fest": "^5.8.0"
83
95
  },
84
96
  "devDependencies": {
package/sdk/download.ts CHANGED
@@ -14,45 +14,25 @@
14
14
  * never holds it: a web stream piped to a write stream.
15
15
  */
16
16
 
17
- import { spawn } from "node:child_process"
18
17
  import { createWriteStream, existsSync } from "node:fs"
19
18
  import { rename } from "node:fs/promises"
20
19
  import { Readable } from "node:stream"
21
20
  import { pipeline } from "node:stream/promises"
22
21
 
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
- }
22
+ import { verifyZipIntegrity } from "@mailwoman/core/fs/zip"
43
23
 
44
24
  /**
45
25
  * Download `url` to `dest` unless a VALID copy is already there. Returns `true` when the cache was reused, `false` when
46
26
  * a download happened.
47
27
  *
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.
28
+ * "Valid" means every member's CRC-32 checks out — an existence check alone is not enough, because an interrupted
29
+ * download leaves a plausible-looking file that fails only much later, inside ogr2ogr. Writes to a `.tmp` sibling and
30
+ * renames, so `dest` is never a partial archive.
51
31
  */
52
32
  export async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
53
33
  if (existsSync(dest)) {
54
34
  try {
55
- await runCapture("unzip", ["-tq", dest])
35
+ await verifyZipIntegrity(dest)
56
36
 
57
37
  return true
58
38
  } catch {
package/sdk/fetch.ts CHANGED
@@ -25,19 +25,18 @@
25
25
  */
26
26
 
27
27
  import { spawn } from "node:child_process"
28
- import { createWriteStream, existsSync } from "node:fs"
29
- import { mkdir, rename } from "node:fs/promises"
28
+ import { mkdir } from "node:fs/promises"
30
29
  import { dirname, join } from "node:path"
31
30
  import { DatabaseSync } from "node:sqlite"
32
- import { Readable } from "node:stream"
33
- import { pipeline } from "node:stream/promises"
34
31
 
32
+ import { APIClient, pluckResponseData } from "@mailwoman/core/api"
33
+ import { extractZipEntries } from "@mailwoman/core/fs/zip"
35
34
  import { DatabaseClient } from "@mailwoman/core/kysley/client"
36
35
  import { tryParsingJSON } from "@mailwoman/core/objects"
37
36
  import { mailwomanDataRoot } from "@mailwoman/core/utils"
38
37
  import { TextSpliterator } from "spliterator"
39
38
 
40
- import { downloadIfNeeded, runCapture } from "./download.ts"
39
+ import { downloadIfNeeded } from "./download.ts"
41
40
  import type { TIGERBlockTable, TIGERDatabase, TIGERPlaceTable, TIGERStreetTable } from "./schema.ts"
42
41
  import { initializeTIGERSchema, TIGER_PRAGMAS } from "./schema.ts"
43
42
 
@@ -175,10 +174,11 @@ function buildRow(level: TIGERFetchLevel, p: Record<string, unknown>, geometry:
175
174
  * Scrape the ADDRFEAT directory listing for a state's county FIPS codes.
176
175
  */
177
176
  async function discoverCounties(state: string, vintage: number): Promise<string[]> {
178
- const res = await fetch(`${CENSUS_HOST}/geo/tiger/TIGER${vintage}/ADDRFEAT/`, { redirect: "follow" })
177
+ // The LISTING only — a small HTML index. The per-county archives below stay on raw `fetch`, streaming to disk.
178
+ const html = await new APIClient({ displayName: "tiger-listing", retry: true })
179
+ .fetch<string>({ url: `${CENSUS_HOST}/geo/tiger/TIGER${vintage}/ADDRFEAT/`, responseType: "text" })
180
+ .then(pluckResponseData)
179
181
 
180
- if (!res.ok) throw new Error(`HTTP ${res.status} listing ADDRFEAT for vintage ${vintage}`)
181
- const html = await res.text()
182
182
  const re = new RegExp(`tl_${vintage}_${state}(\\d{3})_addrfeat\\.zip`, "g")
183
183
  const counties = new Set<string>()
184
184
 
@@ -221,6 +221,11 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
221
221
  const kdb = new DatabaseClient<TIGERDatabase>({ database: db })
222
222
  await initializeTIGERSchema(kdb)
223
223
 
224
+ /**
225
+ * `level` picks the table AND the row shape, but nothing ties them at the type level: {@link buildRow} returns the
226
+ * union and Kysely needs the arm's concrete row. The narrowings below are sound only because `buildRow` switches on
227
+ * the SAME `level` — keep the two switches in step, or a row shape reaches the wrong table.
228
+ */
224
229
  const insertBatch = async (rows: Row[]): Promise<void> => {
225
230
  if (level === "tabblock20") {
226
231
  await kdb
@@ -270,7 +275,7 @@ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<Fe
270
275
  const cached = await downloadIfNeeded(url, zipPath)
271
276
  yield { phase: "download", file: zipName, cached }
272
277
 
273
- await runCapture("unzip", ["-o", "-q", zipPath, "-d", cacheDir])
278
+ await extractZipEntries(zipPath, cacheDir)
274
279
  const layer = `tl_${vintage}_${unit}_${level}`
275
280
  const shpPath = join(cacheDir, layer + ".shp")
276
281
  yield { phase: "extract", file: layer + ".shp" }
@@ -16,20 +16,17 @@
16
16
  * https://www2.census.gov/programs-surveys/decennial/2020/data/01-Redistricting_File--PL_94-171/
17
17
  */
18
18
 
19
- import { spawn } from "node:child_process"
20
- import { createWriteStream, existsSync } from "node:fs"
21
- import { mkdir, rename } from "node:fs/promises"
19
+ import { mkdir } from "node:fs/promises"
22
20
  import { dirname, join } from "node:path"
23
21
  import { DatabaseSync } from "node:sqlite"
24
- import { Readable } from "node:stream"
25
- import { pipeline } from "node:stream/promises"
26
22
 
23
+ import { extractZipEntries } from "@mailwoman/core/fs/zip"
27
24
  import { DatabaseClient } from "@mailwoman/core/kysley/client"
28
25
  import { mailwomanDataRoot } from "@mailwoman/core/utils"
29
26
  import { TextSpliterator } from "spliterator"
30
27
 
31
28
  import { AdminLevel1CodeToAbbreviation, StateName, type AdminLevel1Code } from "../state.ts"
32
- import { downloadIfNeeded, runCapture } from "./download.ts"
29
+ import { downloadIfNeeded } from "./download.ts"
33
30
  import { initializeTIGERSchema, TIGER_PRAGMAS, type PLBlockTable, type TIGERDatabase } from "./schema.ts"
34
31
 
35
32
  const REDISTRICTING_BASE =
@@ -148,7 +145,7 @@ export async function* fetchRedistricting(
148
145
  const cached = await downloadIfNeeded(url, zipPath)
149
146
  yield { phase: "download", file: zipName, cached }
150
147
 
151
- await runCapture("unzip", ["-o", "-q", zipPath, "-d", cacheDir])
148
+ await extractZipEntries(zipPath, cacheDir)
152
149
  const geoPath = join(cacheDir, `${fileAbbr}geo${vintage}.pl`)
153
150
  const seg1Path = join(cacheDir, `${fileAbbr}00001${vintage}.pl`)
154
151
  yield { phase: "extract", file: `${fileAbbr}geo${vintage}.pl` }
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { smartSnakeCase } from "@mailwoman/core"
8
- import { TIGERProperty } from "@mailwoman/tiger"
8
+
9
+ import { TIGERProperty } from "#index"
9
10
 
10
11
  /**
11
12
  * Default column selections for TIGER tabulated block entities.
@@ -4,7 +4,7 @@
4
4
  * @author Teffen Ellis, et al.
5
5
  */
6
6
 
7
- import type { AdminLevel1Code, FIPSCountyCode, FIPSTractCode } from "@mailwoman/tiger"
7
+ import type { AdminLevel1Code, FIPSCountyCode, FIPSTractCode } from "#index"
8
8
 
9
9
  /**
10
10
  * @internal
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { GeometryLiteral, MultiPolygonLiteral } from "@mailwoman/spatial"
8
8
  import { wellKnownGeometryToGeoJSON } from "@mailwoman/spatial/sdk"
9
+
9
10
  import type {
10
11
  FIPSStateCode,
11
12
  FIPSBlockGeoID,
@@ -13,7 +14,7 @@ import type {
13
14
  TIGERBlockFeature,
14
15
  TIGERBlockFeatureCollection,
15
16
  TIGERTabulatedBlockProperties,
16
- } from "@mailwoman/tiger"
17
+ } from "#index"
17
18
 
18
19
  export interface StateBlockIntersectionCriteria {
19
20
  stateCode: FIPSStateCode
package/tools/index.ts CHANGED
@@ -3,11 +3,7 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * TIGER operator tools — the `run()`-style modules behind `mailwoman tiger race-dots` /
7
- * `race-dots-map` (see the 2026-07-09 scripts→Pastel spec). No argv, no `process.exit`: commands
8
- * own parsing, rendering, and exit codes. Heavy deps (`@turf/boolean-contains`,
9
- * `@protomaps/basemaps`) are lazy-imported inside their entry fns. NOT `sdk/` — that submodule
10
- * means data acquisition.
6
+ * TIGER operator tool exports.
11
7
  */
12
8
 
13
9
  export * from "./race-dots.ts"
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { writeFileSync } from "node:fs"
21
21
 
22
+ import { tempRootPath } from "@mailwoman/core/utils"
23
+
22
24
  /**
23
25
  * Options for {@linkcode raceDotsMap}.
24
26
  */
@@ -104,7 +106,7 @@ export async function raceDotsMap(
104
106
  report?: (line: string) => void
105
107
  ): Promise<RaceDotsMapResult> {
106
108
  const PMTILES_URL = options.pmtilesURL || "http://localhost:8899/race-dots-oc.pmtiles"
107
- const OUT = options.out || "/tmp/race-dots-oc.html"
109
+ const OUT = options.out || tempRootPath("race-dots-oc.html")
108
110
  const PER = options.per ?? 5
109
111
  const PER_PHRASE = PER === 1 ? "one dot is one person" : `one dot ≈ ${PER} people`
110
112
  const TITLE = options.title || `Race in Orange County, CA — ${PER_PHRASE} (2020 Census)`
@@ -25,7 +25,7 @@ import { createWriteStream } from "node:fs"
25
25
  import { DatabaseSync } from "node:sqlite"
26
26
 
27
27
  import { tryParsingJSON } from "@mailwoman/core/objects"
28
- import { dataRootPath } from "@mailwoman/core/utils"
28
+ import { dataRootPath, tempRootPath } from "@mailwoman/core/utils"
29
29
 
30
30
  /**
31
31
  * Attempts to place a dot inside its polygon by rejection sampling before giving up on it.
@@ -112,7 +112,7 @@ export async function raceDots(
112
112
  report?: (line: string) => void
113
113
  ): Promise<RaceDotsResult> {
114
114
  const DB = options.db || dataRootPath("tiger", "tiger-oc.db")
115
- const OUT = options.out || "/tmp/race-dots.ndjson"
115
+ const OUT = options.out || tempRootPath("race-dots.ndjson")
116
116
  const PER = options.per ?? 10 // people represented by one dot
117
117
  const LAYER = options.layer || "dots"
118
118
 
@@ -15,6 +15,8 @@ import { createReadStream, statSync, type Stats } from "node:fs"
15
15
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
16
16
  import { extname, join, normalize } from "node:path"
17
17
 
18
+ import { mailwomanTempRoot } from "@mailwoman/core/utils"
19
+
18
20
  /**
19
21
  * Options for {@linkcode serveWithRangeSupport}.
20
22
  */
@@ -55,7 +57,7 @@ export function serveWithRangeSupport(
55
57
  options: ServeRangeOptions = {},
56
58
  report?: (line: string) => void
57
59
  ): Promise<RangeServer> {
58
- const dir = options.dir || "/tmp"
60
+ const dir = options.dir || mailwomanTempRoot()
59
61
  const port = options.port ?? 8899
60
62
 
61
63
  const server = createServer((req: IncomingMessage, res: ServerResponse) => {