@mailwoman/tiger 1.0.0 → 4.13.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": "1.0.0",
3
+ "version": "4.13.0",
4
4
  "description": "US Census TIGER/Line data processing and analysis.",
5
5
  "license": "AGPL-3.0",
6
6
  "contributors": [
@@ -9,6 +9,15 @@
9
9
  "email": "teffen@sister.software"
10
10
  }
11
11
  ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/sister-software/mailwoman.git",
15
+ "directory": "tiger"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/sister-software/mailwoman/issues"
19
+ },
20
+ "homepage": "https://github.com/sister-software/mailwoman",
12
21
  "scripts": {
13
22
  "test": "node ./out/sdk/TIGERService.test.js"
14
23
  },
@@ -21,9 +30,10 @@
21
30
  ".": "./out/index.js"
22
31
  },
23
32
  "dependencies": {
24
- "@mailwoman/core": "4.11.0",
25
- "@mailwoman/spatial": "4.11.0",
33
+ "@mailwoman/core": "4.13.0",
34
+ "@mailwoman/spatial": "4.13.0",
26
35
  "@turf/boolean-contains": "^7.3.5",
36
+ "kysely": "^0.29.2",
27
37
  "shapefile-parser": "^1.0.3",
28
38
  "type-fest": "^5.7.0"
29
39
  },
package/sdk/fetch.ts ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Pull TIGER geometry/attributes into a `node:sqlite` database via the Kysely
7
+ * {@link DatabaseClient}.
8
+ *
9
+ * Replaces the ad-hoc wget and the retired corpus TIGER build script. Pared down from isp-nexus's
10
+ * `generate-tiger-tiles.ts` (its per-level column mapping is the spec) into the playpen
11
+ * `repo-tools` idiom: an async generator of progress events, idempotent, no SpatiaLite.
12
+ *
13
+ * Three levels:
14
+ *
15
+ * - `tabblock20` (per state) — tabulation blocks → `tabblock20`, geometry as GeoJSON text.
16
+ * - `place` (per state) — incorporated/census places → `tiger_places` (attribute-only).
17
+ * - `addrfeat` (per county) — named street segments + ZIPs → `tiger_streets` (attribute-only).
18
+ *
19
+ * `tiger_streets` + `tiger_places` match the schema the corpus `tiger` adapter reads, so this is a
20
+ * drop-in replacement for the retired corpus TIGER build script.
21
+ *
22
+ * Flow per source unit: download (skips a valid cached zip) → unzip → stream `ogr2ogr -f
23
+ * GeoJSONSeq` (mapping shapefile columns to the schema, WGS84 for geometry levels) → batched
24
+ * inserts. Re-running a state replaces its rows.
25
+ */
26
+
27
+ import { DatabaseClient } from "@mailwoman/core/kysley/client"
28
+ import { spawn } from "node:child_process"
29
+ import { createWriteStream, existsSync } from "node:fs"
30
+ import { mkdir, rename } from "node:fs/promises"
31
+ import { dirname, join } from "node:path"
32
+ import { createInterface } from "node:readline"
33
+ import { DatabaseSync } from "node:sqlite"
34
+ import { Readable } from "node:stream"
35
+ import { pipeline } from "node:stream/promises"
36
+ import type { TIGERBlockTable, TIGERDatabase, TIGERPlaceTable, TIGERStreetTable } from "./schema.js"
37
+ import { initializeTIGERSchema, TIGER_PRAGMAS } from "./schema.js"
38
+
39
+ const CENSUS_HOST = "https://www2.census.gov"
40
+ const DEFAULT_DATA_ROOT = process.env.MAILWOMAN_DATA_ROOT ?? "/mnt/playpen/mailwoman-data"
41
+
42
+ /**
43
+ * Supported TIGER levels. `tabblock20` is per state + carries geometry; `place`/`addrfeat` are
44
+ * attribute-only.
45
+ */
46
+ export type TIGERFetchLevel = "tabblock20" | "place" | "addrfeat"
47
+
48
+ const LEVEL_DIR: Record<TIGERFetchLevel, string> = {
49
+ tabblock20: "TABBLOCK20",
50
+ place: "PLACE",
51
+ addrfeat: "ADDRFEAT",
52
+ }
53
+ const LEVEL_TABLE: Record<TIGERFetchLevel, keyof TIGERDatabase> = {
54
+ tabblock20: "tabblock20",
55
+ place: "tiger_places",
56
+ addrfeat: "tiger_streets",
57
+ }
58
+
59
+ export interface FetchTIGEROptions {
60
+ /** Two-digit state FIPS, e.g. `"06"`. */
61
+ stateFIPS: string
62
+ /** TIGER level. Default `tabblock20`. */
63
+ level?: TIGERFetchLevel
64
+ /** Vintage. Default 2020 for blocks (matches the 2020 P.L.), 2024 for place/addrfeat (current). */
65
+ vintage?: number
66
+ /**
67
+ * Output SQLite path. Default `<dataRoot>/tiger/tiger.db` (the name the corpus `tiger` adapter
68
+ * reads).
69
+ */
70
+ outPath?: string
71
+ /** Download cache + default output root. */
72
+ dataRoot?: string
73
+ /** Optional three-digit county FIPS filter (blocks only — addrfeat is already per-county). */
74
+ county?: string
75
+ /** Rows per insert. Default 1000. */
76
+ batchSize?: number
77
+ }
78
+
79
+ export type FetchTIGEREvent =
80
+ | { phase: "download"; file: string; cached: boolean }
81
+ | { phase: "extract"; file: string }
82
+ | { phase: "load"; inserted: number; total: number }
83
+
84
+ export interface FetchTIGERResult {
85
+ outPath: string
86
+ table: string
87
+ inserted: number
88
+ }
89
+
90
+ /** The isp-nexus column map for `tabblock20`. Geometry rides along implicitly. */
91
+ function blockSelectSQL(layer: string, county?: string): string {
92
+ const where = county ? ` WHERE COUNTYFP20 = '${county}'` : ""
93
+ return (
94
+ `SELECT GEOID20 AS GEOID, STATEFP20 AS state_code, COUNTYFP20 AS county_code, ` +
95
+ `SUBSTR(GEOID20, 6, 6) AS tract_code, ` +
96
+ `SUBSTR(GEOID20, 12, 1) AS block_group_code, BLOCKCE20 AS block_code, ` +
97
+ `UACE20 AS urbanized_area_code, UR20 AS urban_rural_code, ` +
98
+ `HOUSING20 AS housing_unit_count, ALAND20 AS land_area_sqm, AWATER20 AS water_area_sqm, ` +
99
+ `POP20 AS population FROM "${layer}"${where}`
100
+ )
101
+ }
102
+
103
+ function selectSQL(level: TIGERFetchLevel, layer: string, county?: string): string {
104
+ switch (level) {
105
+ case "tabblock20":
106
+ return blockSelectSQL(layer, county)
107
+ case "place":
108
+ return `SELECT GEOID AS geoid, NAME AS name, STATEFP AS statefp, LSAD AS lsad, NAMELSAD AS namelsad, CLASSFP AS classfp FROM "${layer}"`
109
+ case "addrfeat":
110
+ // ADDRFEAT has no STATEFP column — injected per-row from the state we're fetching.
111
+ return `SELECT LINEARID AS linearid, FULLNAME AS fullname, ZIPL AS zipl, ZIPR AS zipr FROM "${layer}" WHERE FULLNAME IS NOT NULL AND FULLNAME != ''`
112
+ }
113
+ }
114
+
115
+ type Row = TIGERBlockTable | TIGERPlaceTable | TIGERStreetTable
116
+
117
+ function buildRow(level: TIGERFetchLevel, p: Record<string, unknown>, geometry: unknown, state: string): Row {
118
+ switch (level) {
119
+ case "place":
120
+ return {
121
+ geoid: String(p.geoid),
122
+ name: String(p.name ?? ""),
123
+ statefp: String(p.statefp ?? state),
124
+ lsad: (p.lsad as string) || null,
125
+ namelsad: (p.namelsad as string) || null,
126
+ classfp: (p.classfp as string) || null,
127
+ }
128
+ case "addrfeat":
129
+ return {
130
+ linearid: String(p.linearid),
131
+ fullname: String(p.fullname ?? ""),
132
+ zipl: (p.zipl as string) || null,
133
+ zipr: (p.zipr as string) || null,
134
+ statefp: state,
135
+ }
136
+ case "tabblock20":
137
+ return {
138
+ GEOID: String(p.GEOID),
139
+ state_code: String(p.state_code),
140
+ county_code: String(p.county_code),
141
+ tract_code: String(p.tract_code ?? ""),
142
+ block_group_code: String(p.block_group_code ?? ""),
143
+ block_code: String(p.block_code ?? ""),
144
+ urbanized_area_code: (p.urbanized_area_code as string) || null,
145
+ urban_rural_code: (p.urban_rural_code as string) || null,
146
+ housing_unit_count: Number(p.housing_unit_count ?? 0),
147
+ land_area_sqm: Number(p.land_area_sqm ?? 0),
148
+ water_area_sqm: Number(p.water_area_sqm ?? 0),
149
+ population: Number(p.population ?? 0),
150
+ geometry: JSON.stringify(geometry),
151
+ }
152
+ }
153
+ }
154
+
155
+ function runCapture(cmd: string, args: string[]): Promise<string> {
156
+ return new Promise((resolve, reject) => {
157
+ const child = spawn(cmd, args)
158
+ let out = ""
159
+ let err = ""
160
+ child.stdout.on("data", (d) => (out += d))
161
+ child.stderr.on("data", (d) => (err += d))
162
+ child.on("error", reject)
163
+ child.on("close", (code) =>
164
+ code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
165
+ )
166
+ })
167
+ }
168
+
169
+ async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
170
+ if (existsSync(dest)) {
171
+ try {
172
+ await runCapture("unzip", ["-tq", dest])
173
+ return true
174
+ } catch {
175
+ // corrupt cache — re-download
176
+ }
177
+ }
178
+ const tmp = dest + ".tmp"
179
+ const res = await fetch(url, { redirect: "follow" })
180
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
181
+ await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
182
+ await rename(tmp, dest)
183
+ return false
184
+ }
185
+
186
+ /** Scrape the ADDRFEAT directory listing for a state's county FIPS codes. */
187
+ async function discoverCounties(state: string, vintage: number): Promise<string[]> {
188
+ const res = await fetch(`${CENSUS_HOST}/geo/tiger/TIGER${vintage}/ADDRFEAT/`, { redirect: "follow" })
189
+ if (!res.ok) throw new Error(`HTTP ${res.status} listing ADDRFEAT for vintage ${vintage}`)
190
+ const html = await res.text()
191
+ const re = new RegExp(`tl_${vintage}_${state}(\\d{3})_addrfeat\\.zip`, "g")
192
+ const counties = new Set<string>()
193
+ for (let m = re.exec(html); m; m = re.exec(html)) counties.add(m[1]!)
194
+ return [...counties].sort()
195
+ }
196
+
197
+ /**
198
+ * Fetch one state's TIGER data at `level` into a SQLite DB. Yields progress; returns the final
199
+ * tally.
200
+ */
201
+ export async function* fetchTIGER(options: FetchTIGEROptions): AsyncGenerator<FetchTIGEREvent, FetchTIGERResult> {
202
+ const level = options.level ?? "tabblock20"
203
+ const vintage = options.vintage ?? (level === "tabblock20" ? 2020 : 2024)
204
+ const dataRoot = options.dataRoot ?? DEFAULT_DATA_ROOT
205
+ const batchSize = options.batchSize ?? 1000
206
+ const state = options.stateFIPS
207
+ const table = LEVEL_TABLE[level]
208
+
209
+ const cacheDir = join(dataRoot, "tiger", String(vintage), state)
210
+ // Default to a stable, vintage-agnostic `tiger.db` — the filename the corpus `tiger` adapter reads
211
+ // (run-corpus-build → `${ROOT}/tiger/tiger.db`). The vintage is a content detail, not a path one;
212
+ // the per-table idempotent delete keeps a re-fetch (newer vintage) clean. The download CACHE stays
213
+ // vintage-partitioned below so zips don't collide across vintages.
214
+ const outPath = options.outPath ?? join(dataRoot, "tiger", "tiger.db")
215
+ await mkdir(cacheDir, { recursive: true })
216
+ await mkdir(dirname(outPath), { recursive: true })
217
+
218
+ // Source units: one (per-state) for block/place; one per county for addrfeat.
219
+ const geoCodes = level === "addrfeat" ? await discoverCounties(state, vintage) : [""]
220
+ if (level === "addrfeat" && geoCodes.length === 0) {
221
+ throw new Error(`No ADDRFEAT counties found for state ${state} vintage ${vintage}`)
222
+ }
223
+
224
+ const db = new DatabaseSync(outPath)
225
+ db.exec(TIGER_PRAGMAS)
226
+ const kdb = new DatabaseClient<TIGERDatabase>({ database: db })
227
+ await initializeTIGERSchema(kdb)
228
+
229
+ const insertBatch = async (rows: Row[]): Promise<void> => {
230
+ if (level === "tabblock20")
231
+ await kdb
232
+ .insertInto("tabblock20")
233
+ .values(rows as TIGERBlockTable[])
234
+ .execute()
235
+ else if (level === "place")
236
+ await kdb
237
+ .insertInto("tiger_places")
238
+ .values(rows as TIGERPlaceTable[])
239
+ .execute()
240
+ else
241
+ await kdb
242
+ .insertInto("tiger_streets")
243
+ .values(rows as TIGERStreetTable[])
244
+ .execute()
245
+ }
246
+
247
+ try {
248
+ // Idempotent re-run: drop this state's rows first (state_code for blocks, statefp otherwise).
249
+ if (level === "tabblock20") await kdb.deleteFrom("tabblock20").where("state_code", "=", state).execute()
250
+ else if (level === "place") await kdb.deleteFrom("tiger_places").where("statefp", "=", state).execute()
251
+ else await kdb.deleteFrom("tiger_streets").where("statefp", "=", state).execute()
252
+
253
+ let inserted = 0
254
+ let batch: Row[] = []
255
+ const flush = async () => {
256
+ if (!batch.length) return
257
+ const rows = batch
258
+ batch = []
259
+ await insertBatch(rows)
260
+ inserted += rows.length
261
+ }
262
+
263
+ for (const geo of geoCodes) {
264
+ const unit = level === "addrfeat" ? state + geo : state
265
+ const zipName = `tl_${vintage}_${unit}_${level}.zip`
266
+ const zipPath = join(cacheDir, zipName)
267
+ const url = `${CENSUS_HOST}/geo/tiger/TIGER${vintage}/${LEVEL_DIR[level]}/${zipName}`
268
+
269
+ const cached = await downloadIfNeeded(url, zipPath)
270
+ yield { phase: "download", file: zipName, cached }
271
+
272
+ await runCapture("unzip", ["-o", "-q", zipPath, "-d", cacheDir])
273
+ const layer = `tl_${vintage}_${unit}_${level}`
274
+ const shpPath = join(cacheDir, layer + ".shp")
275
+ yield { phase: "extract", file: layer + ".shp" }
276
+
277
+ const child = spawn(
278
+ "ogr2ogr",
279
+ [
280
+ "-f",
281
+ "GeoJSONSeq",
282
+ "-t_srs",
283
+ "EPSG:4326",
284
+ "-sql",
285
+ selectSQL(level, layer, options.county),
286
+ "/vsistdout/",
287
+ shpPath,
288
+ ],
289
+ { stdio: ["ignore", "pipe", "pipe"] }
290
+ )
291
+ let stderr = ""
292
+ child.stderr.on("data", (d) => (stderr += d))
293
+ const exited = new Promise<number>((resolve) => child.on("close", (code) => resolve(code ?? 0)))
294
+
295
+ for await (const line of createInterface({ input: child.stdout, crlfDelay: Infinity })) {
296
+ if (!line) continue
297
+ let feat: { properties?: Record<string, unknown>; geometry?: unknown }
298
+ try {
299
+ feat = JSON.parse(line)
300
+ } catch {
301
+ continue
302
+ }
303
+ if (!feat.properties) continue
304
+ if (level === "tabblock20" && !feat.geometry) continue
305
+ batch.push(buildRow(level, feat.properties, feat.geometry, state))
306
+ if (batch.length >= batchSize) {
307
+ await flush()
308
+ yield { phase: "load", inserted, total: 0 }
309
+ }
310
+ }
311
+ await flush()
312
+
313
+ const code = await exited
314
+ if (code !== 0) throw new Error(`ogr2ogr exited ${code} on ${layer}: ${stderr.slice(0, 500)}`)
315
+ yield { phase: "load", inserted, total: 0 }
316
+ }
317
+
318
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE);")
319
+ return { outPath, table, inserted }
320
+ } finally {
321
+ await kdb.destroy()
322
+ }
323
+ }
package/sdk/index.ts CHANGED
@@ -4,4 +4,7 @@
4
4
  * @author Teffen Ellis, et al.
5
5
  */
6
6
 
7
+ export * from "./fetch.js"
8
+ export * from "./redistricting.js"
9
+ export * from "./schema.js"
7
10
  export * from "./state/index.js"
@@ -0,0 +1,223 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * Pull a state's Census 2020 P.L. 94-171 redistricting counts (table P2 — Hispanic-or-Latino by
7
+ * race, per block) into the `pl_block` table of a TIGER {@link DatabaseClient} DB, keyed on the
8
+ * same 15-char block `GEOID` as {@link fetchTIGER}'s `tabblock20`. Join the two for block-level
9
+ * race + geometry (e.g. a dot-density map).
10
+ *
11
+ * Keyless public data. The per-state ZIP holds a pipe-delimited geographic header
12
+ * (`<st>geo<yr>.pl`) and three data segments; segment 1 (`<st>00001<yr>.pl`) carries P1 + P2. We
13
+ * join the header (filtered to SUMLEV 750 = block) to segment 1 by LOGRECNO. Field offsets are
14
+ * fixed by the 2020 P.L. layout (verified against the real files).
15
+ *
16
+ * https://www2.census.gov/programs-surveys/decennial/2020/data/01-Redistricting_File--PL_94-171/
17
+ */
18
+
19
+ import { DatabaseClient } from "@mailwoman/core/kysley/client"
20
+ import { spawn } from "node:child_process"
21
+ import { createReadStream, createWriteStream, existsSync } from "node:fs"
22
+ import { mkdir, rename } from "node:fs/promises"
23
+ import { dirname, join } from "node:path"
24
+ import { createInterface } from "node:readline"
25
+ import { DatabaseSync } from "node:sqlite"
26
+ import { Readable } from "node:stream"
27
+ import { pipeline } from "node:stream/promises"
28
+ import { AdminLevel1CodeToAbbreviation, StateName, type AdminLevel1Code } from "../state.js"
29
+ import { initializeTIGERSchema, TIGER_PRAGMAS, type PLBlockTable, type TIGERDatabase } from "./schema.js"
30
+
31
+ const REDISTRICTING_BASE =
32
+ "https://www2.census.gov/programs-surveys/decennial/2020/data/01-Redistricting_File--PL_94-171"
33
+ const DEFAULT_DATA_ROOT = process.env.MAILWOMAN_DATA_ROOT ?? "/mnt/playpen/mailwoman-data"
34
+
35
+ // P.L. 94-171 (2020) pipe-delimited field offsets (0-based).
36
+ // Geographic header: …|SUMLEV(2)|…|LOGRECNO(7)|GEOID(8)|GEOCODE(9)|… — GEOCODE is the bare 15-char
37
+ // block FIPS (matches TIGER GEOID20); SUMLEV 750 = tabulation block.
38
+ const GEO_SUMLEV = 2
39
+ const GEO_LOGRECNO = 7
40
+ const GEO_GEOCODE = 9
41
+ // Segment 1: FILEID|STUSAB|CHARITER|CIFSN|LOGRECNO(4)| P1×71 | P2×73. P0020001 is at index 76.
42
+ const SEG_LOGRECNO = 4
43
+ const P2 = (fieldNo: number) => 76 + (fieldNo - 1)
44
+ // The eight P2 categories that partition the total (P0020001), in `pl_block` column order.
45
+ const CATEGORY_INDEX = {
46
+ pop_total: P2(1),
47
+ hispanic: P2(2), // Hispanic or Latino (any race)
48
+ white: P2(5), // Not Hispanic: White alone
49
+ black: P2(6),
50
+ aian: P2(7),
51
+ asian: P2(8),
52
+ nhpi: P2(9),
53
+ other: P2(10),
54
+ multi: P2(11), // Two or more races
55
+ } as const
56
+
57
+ export interface FetchRedistrictingOptions {
58
+ /** Two-digit state FIPS, e.g. `"06"`. */
59
+ stateFIPS: string
60
+ /** Decennial vintage. Default 2020 (the only P.L. 94-171 release this parses). */
61
+ vintage?: number
62
+ /** Output SQLite path. Default `<dataRoot>/tiger/tiger.db` (same DB as `fetchTIGER`). */
63
+ outPath?: string
64
+ /** Download cache + default output root. */
65
+ dataRoot?: string
66
+ /** Optional three-digit county FIPS filter, e.g. `"059"`. */
67
+ county?: string
68
+ /** Rows per insert. Default 2000. */
69
+ batchSize?: number
70
+ }
71
+
72
+ export type FetchRedistrictingEvent =
73
+ | { phase: "download"; file: string; cached: boolean }
74
+ | { phase: "extract"; file: string }
75
+ | { phase: "header"; blocks: number }
76
+ | { phase: "load"; inserted: number; total: number }
77
+
78
+ export interface FetchRedistrictingResult {
79
+ outPath: string
80
+ table: string
81
+ inserted: number
82
+ }
83
+
84
+ function runCapture(cmd: string, args: string[]): Promise<string> {
85
+ return new Promise((resolve, reject) => {
86
+ const child = spawn(cmd, args)
87
+ let out = ""
88
+ let err = ""
89
+ child.stdout.on("data", (d) => (out += d))
90
+ child.stderr.on("data", (d) => (err += d))
91
+ child.on("error", reject)
92
+ child.on("close", (code) =>
93
+ code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.slice(0, 500)}`))
94
+ )
95
+ })
96
+ }
97
+
98
+ async function downloadIfNeeded(url: string, dest: string): Promise<boolean> {
99
+ if (existsSync(dest)) {
100
+ try {
101
+ await runCapture("unzip", ["-tq", dest])
102
+ return true
103
+ } catch {
104
+ // corrupt cache — re-download
105
+ }
106
+ }
107
+ const tmp = dest + ".tmp"
108
+ const res = await fetch(url, { redirect: "follow" })
109
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} fetching ${url}`)
110
+ await pipeline(Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmp))
111
+ await rename(tmp, dest)
112
+ return false
113
+ }
114
+
115
+ async function eachLine(path: string, fn: (line: string) => void): Promise<void> {
116
+ for await (const line of createInterface({ input: createReadStream(path), crlfDelay: Infinity })) {
117
+ if (line) fn(line)
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Fetch one state's P.L. 94-171 block race counts into `pl_block`. Yields progress; returns the
123
+ * tally.
124
+ */
125
+ export async function* fetchRedistricting(
126
+ options: FetchRedistrictingOptions
127
+ ): AsyncGenerator<FetchRedistrictingEvent, FetchRedistrictingResult> {
128
+ const vintage = options.vintage ?? 2020
129
+ const dataRoot = options.dataRoot ?? DEFAULT_DATA_ROOT
130
+ const batchSize = options.batchSize ?? 2000
131
+ const state = options.stateFIPS
132
+
133
+ const abbr = AdminLevel1CodeToAbbreviation[state as AdminLevel1Code]
134
+ if (!abbr) throw new Error(`Unknown state FIPS "${state}"`)
135
+ const stateName = StateName[abbr as keyof typeof StateName]
136
+ const dirName = stateName.replace(/ /g, "_")
137
+ const fileAbbr = abbr.toLowerCase()
138
+
139
+ const cacheDir = join(dataRoot, "census", "redistricting", String(vintage), state)
140
+ // Same stable `tiger.db` default as fetchTIGER — pl_block lives alongside tabblock20 in one DB.
141
+ const outPath = options.outPath ?? join(dataRoot, "tiger", "tiger.db")
142
+ await mkdir(cacheDir, { recursive: true })
143
+ await mkdir(dirname(outPath), { recursive: true })
144
+
145
+ const zipName = `${fileAbbr}${vintage}.pl.zip`
146
+ const zipPath = join(cacheDir, zipName)
147
+ const url = `${REDISTRICTING_BASE}/${dirName}/${zipName}`
148
+
149
+ const cached = await downloadIfNeeded(url, zipPath)
150
+ yield { phase: "download", file: zipName, cached }
151
+
152
+ await runCapture("unzip", ["-o", "-q", zipPath, "-d", cacheDir])
153
+ const geoPath = join(cacheDir, `${fileAbbr}geo${vintage}.pl`)
154
+ const seg1Path = join(cacheDir, `${fileAbbr}00001${vintage}.pl`)
155
+ yield { phase: "extract", file: `${fileAbbr}geo${vintage}.pl` }
156
+
157
+ // Pass 1: header → LOGRECNO → GEOID for the blocks we want.
158
+ const prefix = options.county ? state + options.county : state
159
+ const logToGeoid = new Map<string, string>()
160
+ await eachLine(geoPath, (line) => {
161
+ const f = line.split("|")
162
+ if (f[GEO_SUMLEV] !== "750") return
163
+ const geoid = f[GEO_GEOCODE] ?? ""
164
+ if (!geoid.startsWith(prefix)) return
165
+ logToGeoid.set(f[GEO_LOGRECNO] ?? "", geoid)
166
+ })
167
+ const total = logToGeoid.size
168
+ yield { phase: "header", blocks: total }
169
+
170
+ const db = new DatabaseSync(outPath)
171
+ db.exec(TIGER_PRAGMAS)
172
+ const kdb = new DatabaseClient<TIGERDatabase>({ database: db })
173
+ await initializeTIGERSchema(kdb)
174
+
175
+ try {
176
+ // Idempotent re-run: drop the rows we're about to (re)load.
177
+ await kdb
178
+ .deleteFrom("pl_block")
179
+ .where("GEOID", "like", prefix + "%")
180
+ .execute()
181
+
182
+ let inserted = 0
183
+ let batch: PLBlockTable[] = []
184
+ const flush = async () => {
185
+ if (!batch.length) return
186
+ const rows = batch
187
+ batch = []
188
+ await kdb.insertInto("pl_block").values(rows).execute()
189
+ inserted += rows.length
190
+ }
191
+
192
+ // Pass 2: segment 1 → P2 counts for the mapped LOGRECNOs, flushing as we go.
193
+ for await (const line of createInterface({ input: createReadStream(seg1Path), crlfDelay: Infinity })) {
194
+ if (!line) continue
195
+ const f = line.split("|")
196
+ const geoid = logToGeoid.get(f[SEG_LOGRECNO] ?? "")
197
+ if (!geoid) continue
198
+ batch.push({
199
+ GEOID: geoid,
200
+ pop_total: Number(f[CATEGORY_INDEX.pop_total] ?? 0),
201
+ hispanic: Number(f[CATEGORY_INDEX.hispanic] ?? 0),
202
+ white: Number(f[CATEGORY_INDEX.white] ?? 0),
203
+ black: Number(f[CATEGORY_INDEX.black] ?? 0),
204
+ aian: Number(f[CATEGORY_INDEX.aian] ?? 0),
205
+ asian: Number(f[CATEGORY_INDEX.asian] ?? 0),
206
+ nhpi: Number(f[CATEGORY_INDEX.nhpi] ?? 0),
207
+ other: Number(f[CATEGORY_INDEX.other] ?? 0),
208
+ multi: Number(f[CATEGORY_INDEX.multi] ?? 0),
209
+ })
210
+ if (batch.length >= batchSize) {
211
+ await flush()
212
+ yield { phase: "load", inserted, total }
213
+ }
214
+ }
215
+ await flush()
216
+
217
+ yield { phase: "load", inserted, total }
218
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE);")
219
+ return { outPath, table: "pl_block", inserted }
220
+ } finally {
221
+ await kdb.destroy()
222
+ }
223
+ }