@mailwoman/corpus 7.2.0 → 7.2.1
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 +59 -27
- package/src/adapter.ts +153 -0
- package/src/adapters/ban/adapter.ts +189 -0
- package/src/adapters/ban/street-decompose.ts +98 -0
- package/src/adapters/fcc-bdc/adapter.ts +188 -0
- package/src/adapters/geonames/adapter.ts +185 -0
- package/src/adapters/geonames-postal/adapter.ts +111 -0
- package/src/adapters/gnaf/adapter.ts +139 -0
- package/src/adapters/gnaf/assemble.ts +200 -0
- package/src/adapters/index.ts +135 -0
- package/src/adapters/openaddresses/adapter.ts +237 -0
- package/src/adapters/overture/adapter.ts +149 -0
- package/src/adapters/state-hi-schools/adapter.ts +171 -0
- package/src/adapters/state-ia-contractors/adapter.ts +148 -0
- package/src/adapters/state-ny-notaries/adapter.ts +169 -0
- package/src/adapters/state-tx-notaries/adapter.ts +160 -0
- package/src/adapters/synth-po-box/adapter.ts +197 -0
- package/src/adapters/tiger/adapter.ts +232 -0
- package/src/adapters/tiger/street-decompose.ts +120 -0
- package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
- package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
- package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
- package/src/adapters/usgov-nad/adapter.ts +338 -0
- package/src/adapters/usgov-nppes/adapter.ts +169 -0
- package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
- package/src/adapters/wof-admin-jp/adapter.ts +176 -0
- package/src/adapters/wof-admin-json/adapter.ts +280 -0
- package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
- package/src/align.ts +317 -0
- package/src/build.ts +348 -0
- package/src/codex/us-fips-state.ts +109 -0
- package/src/format.ts +14 -0
- package/src/golden.ts +147 -0
- package/src/index.ts +28 -0
- package/src/license.ts +48 -0
- package/src/parquet-wrapper/index.ts +9 -0
- package/src/parquet-wrapper/reader.ts +67 -0
- package/src/parquet-wrapper/schema.ts +80 -0
- package/src/parquet-wrapper/writer.ts +102 -0
- package/src/parquet.ts +360 -0
- package/src/runner.ts +283 -0
- package/src/shard-recipes/anchor-absorption.ts +86 -0
- package/src/shard-recipes/boundary-stress.ts +87 -0
- package/src/shard-recipes/country-balanced.ts +471 -0
- package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
- package/src/shard-recipes/fr-admin-split.ts +212 -0
- package/src/shard-recipes/fr-bare-street.ts +85 -0
- package/src/shard-recipes/fr-fragment.ts +304 -0
- package/src/shard-recipes/fr-order.ts +283 -0
- package/src/shard-recipes/german.ts +206 -0
- package/src/shard-recipes/house-venue.ts +78 -0
- package/src/shard-recipes/index.ts +76 -0
- package/src/shard-recipes/intersection.ts +617 -0
- package/src/shard-recipes/locale.ts +415 -0
- package/src/shard-recipes/nl-postcode.ts +105 -0
- package/src/shard-recipes/no-fragment.ts +206 -0
- package/src/shard-recipes/no-street-led.ts +130 -0
- package/src/shard-recipes/no-street.ts +78 -0
- package/src/shard-recipes/po-box-cedex.ts +853 -0
- package/src/shard-recipes/po-box.ts +111 -0
- package/src/shard-recipes/scaffold.ts +187 -0
- package/src/shard-recipes/si-bare-village.ts +88 -0
- package/src/shard-recipes/street-affix.ts +549 -0
- package/src/shard-recipes/street-bare.ts +90 -0
- package/src/shard-recipes/street.ts +74 -0
- package/src/shard-recipes/unit.ts +315 -0
- package/src/split.ts +251 -0
- package/src/synthesize-anchor-absorption.ts +263 -0
- package/src/synthesize-boundary-stress.ts +504 -0
- package/src/synthesize-german.ts +227 -0
- package/src/synthesize-house-venue.ts +192 -0
- package/src/synthesize-intersection.ts +205 -0
- package/src/synthesize-no-street.ts +341 -0
- package/src/synthesize-po-box.ts +307 -0
- package/src/synthesize-street.ts +264 -0
- package/src/synthesize.ts +872 -0
- package/src/tokenize.ts +68 -0
- package/src/tools/align-shard.ts +67 -0
- package/src/tools/audit.ts +352 -0
- package/src/tools/corpus-stats.ts +183 -0
- package/src/tools/fetch/ban.ts +288 -0
- package/src/tools/fetch/download.ts +119 -0
- package/src/tools/fetch/hrsa.ts +65 -0
- package/src/tools/fetch/imls-pls.ts +162 -0
- package/src/tools/fetch/index.ts +121 -0
- package/src/tools/fetch/nad.ts +304 -0
- package/src/tools/fetch/nppes.ts +177 -0
- package/src/tools/fetch/openaddresses.ts +380 -0
- package/src/tools/fetch/state-hi-schools.ts +217 -0
- package/src/tools/fetch/state-sources.ts +142 -0
- package/src/tools/fetch/tiger-full.ts +318 -0
- package/src/tools/golden-expand.ts +524 -0
- package/src/tools/golden-promote.ts +304 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/ingest-csv.ts +420 -0
- package/src/tools/jsonl-to-parquet.ts +200 -0
- package/src/tools/lint-shard-vocab.ts +357 -0
- package/src/tools/lint-shard.ts +502 -0
- package/src/tools/overlay-manifest.ts +141 -0
- package/src/tools/shard-kryptonite.ts +131 -0
- package/src/tools/shard-translit.ts +244 -0
- package/src/types.ts +213 -0
- package/src/wof-json.ts +236 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* CSV → SQLite ingestion utility. Ported from isp-nexus's `sdk/data/csv.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Reads a CSV file, infers column types from a sample of rows, creates a SQLite table, and imports
|
|
9
|
+
* the data. Handles quoted fields, NULL normalization, and duplicate column name disambiguation.
|
|
10
|
+
*
|
|
11
|
+
* ## Usage
|
|
12
|
+
*
|
|
13
|
+
* ```sh
|
|
14
|
+
* mailwoman corpus ingest-csv \
|
|
15
|
+
* --input /data/corpus/sources/usgov-nppes/npidata_pfile.csv \
|
|
16
|
+
* --table nppes_providers \
|
|
17
|
+
* --output /data/corpus/sources/usgov-nppes/nppes.db
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Options: --input <path> CSV file to ingest (required) --table <name> SQLite table name (default:
|
|
21
|
+
* derived from input filename) --output <path> SQLite database path (default: input dir /
|
|
22
|
+
* table.db) --sample <n> Rows to sample for type inference (default: 100) --separator <char>
|
|
23
|
+
* Field separator (default: ,) --skip <n> Lines to skip before header (default: 0) --no-header
|
|
24
|
+
* CSV has no header row — columns will be col_0, col_1, etc. --dry-run Infer schema and print
|
|
25
|
+
* CREATE TABLE, but don't import
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
29
|
+
import { basename, dirname, extname, join } from "node:path"
|
|
30
|
+
import type { SQLInputValue } from "node:sqlite"
|
|
31
|
+
|
|
32
|
+
import { TextSpliterator } from "spliterator"
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Core: quote-aware CSV field splitting
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const COMMA = 44
|
|
39
|
+
const DOUBLE_QUOTE = 34
|
|
40
|
+
|
|
41
|
+
// These stay hand-rolled on purpose — spliterator ≥ 3.2.0's CSVSpliterator does quote handling
|
|
42
|
+
// correctly now, but it can't express this tool's CLI contract: `--skip N` drops N preamble lines
|
|
43
|
+
// BEFORE the header (CSVSpliterator consumes row 1 as the header immediately, with no skip-first-N
|
|
44
|
+
// hook), `--no-header` auto-generates `col_0…col_N` names from the first data row's width, and the
|
|
45
|
+
// separator is any single caller-supplied byte. If a future edit removes those knobs, revisit;
|
|
46
|
+
// until then, don't "finish the job" by swapping in CSVSpliterator.
|
|
47
|
+
function splitCSVLine(line: string, separator: number = COMMA): string[] {
|
|
48
|
+
const fields: string[] = []
|
|
49
|
+
let start = 0
|
|
50
|
+
let inQuotes = false
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < line.length; i++) {
|
|
53
|
+
const ch = line.charCodeAt(i)
|
|
54
|
+
|
|
55
|
+
if (ch === DOUBLE_QUOTE) {
|
|
56
|
+
inQuotes = !inQuotes
|
|
57
|
+
} else if (ch === separator && !inQuotes) {
|
|
58
|
+
fields.push(line.slice(start, i))
|
|
59
|
+
start = i + 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
fields.push(line.slice(start))
|
|
63
|
+
|
|
64
|
+
return fields
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stripQuotes(field: string): string {
|
|
68
|
+
const trimmed = field.trim()
|
|
69
|
+
|
|
70
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
71
|
+
return trimmed.slice(1, -1).replace(/""/g, '"')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return trimmed
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Column name normalization
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function normalizeColumnName(raw: string): string {
|
|
82
|
+
return (
|
|
83
|
+
raw
|
|
84
|
+
.trim()
|
|
85
|
+
.replace(/^"+|"+$/g, "")
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
88
|
+
.replace(/^_|_$/g, "") || "unnamed"
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function dedupColumns(names: string[]): string[] {
|
|
93
|
+
const seen = new Map<string, number>()
|
|
94
|
+
|
|
95
|
+
return names.map((name) => {
|
|
96
|
+
const count = seen.get(name) ?? 0
|
|
97
|
+
seen.set(name, count + 1)
|
|
98
|
+
|
|
99
|
+
return count === 0 ? name : `${name}_${count + 1}`
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Type inference
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
type SQLiteColType = "INTEGER" | "REAL" | "TEXT"
|
|
108
|
+
|
|
109
|
+
interface ColumnInfo {
|
|
110
|
+
name: string
|
|
111
|
+
type: SQLiteColType
|
|
112
|
+
nullable: boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeField(raw: string): string | null {
|
|
116
|
+
let s = raw.trim()
|
|
117
|
+
|
|
118
|
+
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
|
|
119
|
+
s = s.slice(1, -1).replace(/""/g, '"')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Normalize common null-like values
|
|
123
|
+
if (!s || s === "null" || s === "NULL" || s === "N/A" || s === "n/a" || s === "-" || s === "<UNAVAIL>") {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return s
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function inferColumnType(samples: (string | null)[]): ColumnInfo {
|
|
131
|
+
let nullCount = 0
|
|
132
|
+
let intCount = 0
|
|
133
|
+
let realCount = 0
|
|
134
|
+
let textCount = 0
|
|
135
|
+
|
|
136
|
+
for (const s of samples) {
|
|
137
|
+
if (s === null) {
|
|
138
|
+
nullCount++
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (/^-?\d+$/.test(s)) {
|
|
143
|
+
intCount++
|
|
144
|
+
} else if (/^-?\d+\.?\d+$/.test(s)) {
|
|
145
|
+
realCount++
|
|
146
|
+
} else {
|
|
147
|
+
textCount++
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const total = samples.length
|
|
152
|
+
const type: SQLiteColType = realCount / total >= 0.5 ? "REAL" : intCount / total >= 0.5 ? "INTEGER" : "TEXT"
|
|
153
|
+
|
|
154
|
+
return { name: "", type, nullable: nullCount / total >= 0.5 }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Main: read CSV, infer schema, produce SQL
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
interface IngestOptions {
|
|
162
|
+
inputPath: string
|
|
163
|
+
tableName: string
|
|
164
|
+
outputPath: string
|
|
165
|
+
sampleSize: number
|
|
166
|
+
separator: string
|
|
167
|
+
skipLines: number
|
|
168
|
+
hasHeader: boolean
|
|
169
|
+
dryRun: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runIngest(opts: IngestOptions): Promise<void> {
|
|
173
|
+
const sep = opts.separator.charCodeAt(0)
|
|
174
|
+
|
|
175
|
+
// --- Pass 1: read header + sample rows for type inference ---
|
|
176
|
+
process.stderr.write(`Reading ${opts.inputPath} for schema inference...\n`)
|
|
177
|
+
|
|
178
|
+
// CRLF-safe by construction: every field below flows through stripQuotes/normalizeField, both of
|
|
179
|
+
// which `.trim()`, so a trailing CR on the last column of a CRLF file is stripped. TextSpliterator's
|
|
180
|
+
// default skipEmpty matches readline row-for-row on files with a trailing newline (the common case);
|
|
181
|
+
// it drops interior blank lines that readline would have turned into all-null rows. The early `break`
|
|
182
|
+
// closes the file descriptor.
|
|
183
|
+
let headerLine: string | null = null
|
|
184
|
+
const sampleRows: string[][] = []
|
|
185
|
+
let lineNum = 0
|
|
186
|
+
|
|
187
|
+
for await (const line of TextSpliterator.fromAsync(opts.inputPath)) {
|
|
188
|
+
lineNum++
|
|
189
|
+
|
|
190
|
+
// Skip lines before header
|
|
191
|
+
if (lineNum <= opts.skipLines) continue
|
|
192
|
+
|
|
193
|
+
if (!headerLine && opts.hasHeader) {
|
|
194
|
+
headerLine = line
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (sampleRows.length < opts.sampleSize) {
|
|
199
|
+
sampleRows.push(splitCSVLine(line, sep).map(stripQuotes))
|
|
200
|
+
} else {
|
|
201
|
+
break
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!headerLine && opts.hasHeader) {
|
|
206
|
+
throw new Error("No header line found in CSV")
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Determine column names ---
|
|
210
|
+
let rawHeaders: string[]
|
|
211
|
+
|
|
212
|
+
if (opts.hasHeader && headerLine) {
|
|
213
|
+
rawHeaders = splitCSVLine(headerLine, sep).map(stripQuotes)
|
|
214
|
+
} else {
|
|
215
|
+
// Auto-generate column names: col_0, col_1, ...
|
|
216
|
+
const numCols = sampleRows[0]?.length ?? 0
|
|
217
|
+
rawHeaders = Array.from({ length: numCols }, (_, i) => `col_${i}`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const colNames = dedupColumns(rawHeaders.map(normalizeColumnName))
|
|
221
|
+
|
|
222
|
+
// --- Infer column types ---
|
|
223
|
+
const columns: ColumnInfo[] = colNames.map((name, i) => {
|
|
224
|
+
const samples = sampleRows.map((row) => {
|
|
225
|
+
const raw = row[i] ?? ""
|
|
226
|
+
|
|
227
|
+
return normalizeField(raw)
|
|
228
|
+
})
|
|
229
|
+
const info = inferColumnType(samples)
|
|
230
|
+
info.name = name
|
|
231
|
+
|
|
232
|
+
return info
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// --- Generate SQL ---
|
|
236
|
+
const colDefs = columns.map((c) => `"${c.name}" ${c.type}`).join(",\n ")
|
|
237
|
+
// Raw DDL by design: the column set + types are INFERRED from the CSV at runtime (colDefs above),
|
|
238
|
+
// so a Kysely builder loop would just wrap the same dynamic strings with ceremony and no type safety.
|
|
239
|
+
const createTableSQL = `CREATE TABLE IF NOT EXISTS "${opts.tableName}" (\n ${colDefs}\n);`
|
|
240
|
+
|
|
241
|
+
const tempCols = columns.map((c) => `"${c.name}"`).join(", ")
|
|
242
|
+
const insertSQL = `INSERT INTO "${opts.tableName}" (${tempCols})\nSELECT ${tempCols} FROM temp."${opts.tableName}_source";`
|
|
243
|
+
|
|
244
|
+
process.stderr.write(`\nSchema inferred from ${sampleRows.length} sample rows:\n`)
|
|
245
|
+
process.stderr.write(`${createTableSQL}\n\n`)
|
|
246
|
+
|
|
247
|
+
if (opts.dryRun) {
|
|
248
|
+
process.stderr.write("--dry-run: stopping before import\n")
|
|
249
|
+
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- Create database + import ---
|
|
254
|
+
const { DatabaseSync } = await import("node:sqlite")
|
|
255
|
+
mkdirSync(dirname(opts.outputPath), { recursive: true })
|
|
256
|
+
|
|
257
|
+
const db = new DatabaseSync(opts.outputPath)
|
|
258
|
+
db.exec("PRAGMA journal_mode = OFF") // faster for bulk import
|
|
259
|
+
db.exec("PRAGMA synchronous = OFF")
|
|
260
|
+
|
|
261
|
+
db.exec(createTableSQL)
|
|
262
|
+
|
|
263
|
+
// Use the .import approach via a temp table, then INSERT INTO ... SELECT to handle
|
|
264
|
+
// NULL normalization and type coercion.
|
|
265
|
+
const csvBasename = basename(opts.inputPath)
|
|
266
|
+
const importSQL = [
|
|
267
|
+
`CREATE TEMP TABLE "${opts.tableName}_source" (${colDefs});`,
|
|
268
|
+
`.mode csv`,
|
|
269
|
+
`.separator "${opts.separator}"`,
|
|
270
|
+
`.import "${csvBasename}" --skip ${opts.skipLines + (opts.hasHeader ? 1 : 0)} --schema temp ${opts.tableName}_source`,
|
|
271
|
+
insertSQL,
|
|
272
|
+
`DROP TABLE temp."${opts.tableName}_source";`,
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
// better-sqlite3 doesn't support .import natively, so we use a different approach:
|
|
276
|
+
// Read the CSV line-by-line and INSERT in a transaction.
|
|
277
|
+
process.stderr.write(`Importing rows...\n`)
|
|
278
|
+
|
|
279
|
+
const insertStmt = db.prepare(
|
|
280
|
+
`INSERT INTO "${opts.tableName}" (${tempCols}) VALUES (${columns.map(() => "?").join(", ")})`
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
let imported = 0
|
|
284
|
+
let headerSkipped = false
|
|
285
|
+
|
|
286
|
+
// node:sqlite has no `db.transaction(fn)` wrapper; use raw BEGIN/COMMIT around the batch.
|
|
287
|
+
const doInsert = () => {
|
|
288
|
+
db.exec("BEGIN")
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
for (const row of batch) {
|
|
292
|
+
insertStmt.run(...row)
|
|
293
|
+
}
|
|
294
|
+
db.exec("COMMIT")
|
|
295
|
+
} catch (err) {
|
|
296
|
+
db.exec("ROLLBACK")
|
|
297
|
+
throw err
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const batch: SQLInputValue[][] = []
|
|
302
|
+
const BATCH_SIZE = 10000
|
|
303
|
+
|
|
304
|
+
for await (const line of TextSpliterator.fromAsync(opts.inputPath)) {
|
|
305
|
+
lineNum++
|
|
306
|
+
|
|
307
|
+
if (lineNum <= opts.skipLines) continue
|
|
308
|
+
|
|
309
|
+
if (opts.hasHeader && !headerSkipped) {
|
|
310
|
+
headerSkipped = true
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const fields = splitCSVLine(line, sep).map(stripQuotes)
|
|
315
|
+
const values = fields.map((f, i) => {
|
|
316
|
+
const v = normalizeField(f)
|
|
317
|
+
|
|
318
|
+
if (v === null) return null
|
|
319
|
+
const col = columns[i]
|
|
320
|
+
|
|
321
|
+
if (col?.type === "INTEGER" && /^-?\d+$/.test(v)) return parseInt(v, 10)
|
|
322
|
+
|
|
323
|
+
if (col?.type === "REAL" && /^-?\d+\.?\d+$/.test(v)) return parseFloat(v)
|
|
324
|
+
|
|
325
|
+
return v
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// Pad or truncate to column count
|
|
329
|
+
while (values.length < columns.length) {
|
|
330
|
+
values.push(null)
|
|
331
|
+
}
|
|
332
|
+
values.length = columns.length
|
|
333
|
+
|
|
334
|
+
batch.push(values)
|
|
335
|
+
|
|
336
|
+
if (batch.length >= BATCH_SIZE) {
|
|
337
|
+
doInsert()
|
|
338
|
+
imported += batch.length
|
|
339
|
+
batch.length = 0
|
|
340
|
+
|
|
341
|
+
if (imported % 100000 === 0) {
|
|
342
|
+
process.stderr.write(` ${(imported / 1_000_000).toFixed(1)}M rows...\n`)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Flush remaining
|
|
348
|
+
if (batch.length > 0) {
|
|
349
|
+
doInsert()
|
|
350
|
+
imported += batch.length
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
process.stderr.write(` Imported ${imported.toLocaleString()} rows into "${opts.tableName}"\n`)
|
|
354
|
+
|
|
355
|
+
// Build a basic index on the first TEXT column (likely the primary key)
|
|
356
|
+
const firstTextCol = columns.find((c) => c.type === "TEXT")
|
|
357
|
+
|
|
358
|
+
if (firstTextCol) {
|
|
359
|
+
process.stderr.write(`Building index on "${firstTextCol.name}"...\n`)
|
|
360
|
+
db.exec(
|
|
361
|
+
`CREATE INDEX IF NOT EXISTS idx_${opts.tableName}_${firstTextCol.name} ON "${opts.tableName}"("${firstTextCol.name}");`
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
db.close()
|
|
366
|
+
|
|
367
|
+
// Write MANIFEST
|
|
368
|
+
const fileSize = (await import("node:fs/promises")).stat
|
|
369
|
+
const stat = await (await import("node:fs/promises")).stat(opts.outputPath)
|
|
370
|
+
const manifest = {
|
|
371
|
+
ingested_at: new Date().toISOString(),
|
|
372
|
+
source_csv: basename(opts.inputPath),
|
|
373
|
+
table_name: opts.tableName,
|
|
374
|
+
columns: columns.map((c) => ({ name: c.name, type: c.type, nullable: c.nullable })),
|
|
375
|
+
row_count: imported,
|
|
376
|
+
db_bytes: stat.size,
|
|
377
|
+
}
|
|
378
|
+
const manifestPath = opts.outputPath.replace(/\.db$/, ".manifest.json")
|
|
379
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n")
|
|
380
|
+
|
|
381
|
+
process.stderr.write(
|
|
382
|
+
`Done. ${imported.toLocaleString()} rows → ${opts.outputPath} (${(stat.size / 1024 / 1024).toFixed(0)} MB)\n`
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Flag-shaped options for {@linkcode ingestCSV} — `table`/`output` derive from `input` when omitted. */
|
|
387
|
+
export interface IngestCSVOptions {
|
|
388
|
+
input: string
|
|
389
|
+
table?: string
|
|
390
|
+
output?: string
|
|
391
|
+
sample?: number
|
|
392
|
+
separator?: string
|
|
393
|
+
skip?: number
|
|
394
|
+
noHeader?: boolean
|
|
395
|
+
dryRun?: boolean
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Ingest a CSV into SQLite: infer column types from a sample, create the table, import the rows. Throws when `input` is
|
|
400
|
+
* missing. NOTE(phase1): progress narration still writes stderr directly — this predates the report-callback contract
|
|
401
|
+
* and the write sites are deep in the type-inference helpers; thread a report param if a caller ever needs to capture
|
|
402
|
+
* it.
|
|
403
|
+
*/
|
|
404
|
+
export async function ingestCSV(options: IngestCSVOptions): Promise<void> {
|
|
405
|
+
if (!existsSync(options.input)) {
|
|
406
|
+
throw new Error(`File not found: ${options.input}`)
|
|
407
|
+
}
|
|
408
|
+
const csvName = basename(options.input, extname(options.input))
|
|
409
|
+
|
|
410
|
+
await runIngest({
|
|
411
|
+
inputPath: options.input,
|
|
412
|
+
tableName: options.table ?? csvName.replace(/[^a-zA-Z0-9_]/g, "_"),
|
|
413
|
+
outputPath: options.output ?? join(dirname(options.input), csvName + ".db"),
|
|
414
|
+
sampleSize: options.sample ?? 100,
|
|
415
|
+
separator: options.separator ?? ",",
|
|
416
|
+
skipLines: options.skip ?? 0,
|
|
417
|
+
hasHeader: !options.noHeader,
|
|
418
|
+
dryRun: options.dryRun ?? false,
|
|
419
|
+
})
|
|
420
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Convert a JSONL of LabeledRow objects to a Parquet shard matching the v0.5.0 schema.
|
|
7
|
+
*
|
|
8
|
+
* Ported faithfully from scripts/jsonl-to-parquet.py. The Python original wrote Parquet through
|
|
9
|
+
* PyArrow; this writes it through DuckDB (`@duckdb/node-api`) — `read_json` with an EXPLICIT
|
|
10
|
+
* `columns` type map projects the validated rows to the v0.5.0 schema, then `COPY … TO … (FORMAT
|
|
11
|
+
* PARQUET, COMPRESSION SNAPPY, ROW_GROUP_SIZE …)` emits the shard. DuckDB reproduces the exact
|
|
12
|
+
* logical schema PyArrow did — `VARCHAR` (UTF8) scalars, `VARCHAR[]` (LIST<UTF8>) for the string
|
|
13
|
+
* arrays, and `INTEGER[]` (LIST<INT32>) for the span offsets — in the exact column order below.
|
|
14
|
+
* Verified field-for-field against the PyArrow original (same column order, same logical types,
|
|
15
|
+
* same `list<element: …>` child naming, same values), so a PyArrow reader sees an identical
|
|
16
|
+
* table. The trainer in any case reads shards by column name (`pq.read_table(...).to_pylist()`),
|
|
17
|
+
* which is blind to physical layout. INT32 matches the corpus's native TS writer
|
|
18
|
+
* (`@mailwoman/corpus` `LABELED_ROW_SCHEMA`), which already writes the base shards this overlay
|
|
19
|
+
* rides alongside.
|
|
20
|
+
*
|
|
21
|
+
* Schema: raw, tokens, labels, span_starts, span_ends, span_tags, country, locale, source,
|
|
22
|
+
* source_id, corpus_version, license, synth_method, synth_base_id.
|
|
23
|
+
*
|
|
24
|
+
* The span triple (#519, v0.5.0 char-offset labels) is REQUIRED on every row: `alignRow` emits it
|
|
25
|
+
* on every labeled row, so a row arriving without it came from a producer that hasn't migrated —
|
|
26
|
+
* writing it would silently drop the v0.5.0 labels from the shard. Loud failure, naming the row
|
|
27
|
+
* number, instead.
|
|
28
|
+
*
|
|
29
|
+
* Usage: mailwoman dev jsonl-to-parquet --input /tmp/po-box-labeled.jsonl --output
|
|
30
|
+
* /tmp/part-po-box.parquet
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { randomUUID } from "node:crypto"
|
|
34
|
+
import { createWriteStream } from "node:fs"
|
|
35
|
+
import { unlink } from "node:fs/promises"
|
|
36
|
+
import { tmpdir } from "node:os"
|
|
37
|
+
import { join } from "node:path"
|
|
38
|
+
|
|
39
|
+
import { TextSpliterator } from "spliterator"
|
|
40
|
+
|
|
41
|
+
const REQUIRED_COLUMNS = [
|
|
42
|
+
"raw",
|
|
43
|
+
"tokens",
|
|
44
|
+
"labels",
|
|
45
|
+
"span_starts",
|
|
46
|
+
"span_ends",
|
|
47
|
+
"span_tags",
|
|
48
|
+
"country",
|
|
49
|
+
"locale",
|
|
50
|
+
"source",
|
|
51
|
+
"source_id",
|
|
52
|
+
"corpus_version",
|
|
53
|
+
"license",
|
|
54
|
+
"synth_method",
|
|
55
|
+
"synth_base_id",
|
|
56
|
+
] as const
|
|
57
|
+
|
|
58
|
+
const SPAN_COLUMNS = ["span_starts", "span_ends", "span_tags"] as const
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The v0.5.0 DuckDB type for each column, in {@link REQUIRED_COLUMNS} order.
|
|
62
|
+
*
|
|
63
|
+
* Mirrors the PyArrow schema the Python original declared: `pa.string()` → `VARCHAR`, `pa.list_(pa.string())` →
|
|
64
|
+
* `VARCHAR[]`, `pa.list_(pa.int32())` → `INTEGER[]`. The span offsets are INT32 (#519): parallel arrays over `raw`
|
|
65
|
+
* (UTF-16 code units, `[start, end)` exclusive-end, sorted, non-overlapping); `raw` is a short address string, so INT32
|
|
66
|
+
* round-trips as a plain integer where INT64 would surface as bigint.
|
|
67
|
+
*/
|
|
68
|
+
const COLUMN_TYPES: Record<(typeof REQUIRED_COLUMNS)[number], string> = {
|
|
69
|
+
raw: "VARCHAR",
|
|
70
|
+
tokens: "VARCHAR[]",
|
|
71
|
+
labels: "VARCHAR[]",
|
|
72
|
+
span_starts: "INTEGER[]",
|
|
73
|
+
span_ends: "INTEGER[]",
|
|
74
|
+
span_tags: "VARCHAR[]",
|
|
75
|
+
country: "VARCHAR",
|
|
76
|
+
locale: "VARCHAR",
|
|
77
|
+
source: "VARCHAR",
|
|
78
|
+
source_id: "VARCHAR",
|
|
79
|
+
corpus_version: "VARCHAR",
|
|
80
|
+
license: "VARCHAR",
|
|
81
|
+
synth_method: "VARCHAR",
|
|
82
|
+
synth_base_id: "VARCHAR",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Options for {@linkcode jsonlToParquet}. */
|
|
86
|
+
export interface JSONLToParquetOptions {
|
|
87
|
+
/** The labeled-row JSONL to convert. */
|
|
88
|
+
input: string
|
|
89
|
+
/** The parquet shard to write. */
|
|
90
|
+
output: string
|
|
91
|
+
/** Parquet row-group size. Default 50000. */
|
|
92
|
+
rowGroupSize?: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Summary returned by {@linkcode jsonlToParquet}. */
|
|
96
|
+
export interface JSONLToParquetSummary {
|
|
97
|
+
read: number
|
|
98
|
+
written: number
|
|
99
|
+
outPath: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Enforce the #519 span contract per row: all three present, parallel lengths.
|
|
104
|
+
*
|
|
105
|
+
* A row with span_starts but no span_tags is a corrupt row — never a silent fallback.
|
|
106
|
+
*/
|
|
107
|
+
function assertSpanTriple(row: Record<string, unknown>, lineNo: number): void {
|
|
108
|
+
const present = SPAN_COLUMNS.filter((c) => row[c] != null)
|
|
109
|
+
|
|
110
|
+
if (present.length !== SPAN_COLUMNS.length) {
|
|
111
|
+
const missing = SPAN_COLUMNS.filter((c) => row[c] == null)
|
|
112
|
+
throw new Error(
|
|
113
|
+
`line ${lineNo}: row is missing the char-offset span triple (#519): ` +
|
|
114
|
+
`missing ${JSON.stringify(missing)} (source_id=${JSON.stringify(row.source_id ?? null)}). Every parquet-bound row ` +
|
|
115
|
+
"must carry span_starts/span_ends/span_tags; re-emit this shard through alignRow."
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
const n = (row.span_starts as unknown[]).length
|
|
119
|
+
|
|
120
|
+
if ((row.span_ends as unknown[]).length !== n || (row.span_tags as unknown[]).length !== n) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`line ${lineNo}: span triple arrays are not parallel — ` +
|
|
123
|
+
`starts=${(row.span_starts as unknown[]).length} ends=${(row.span_ends as unknown[]).length} ` +
|
|
124
|
+
`tags=${(row.span_tags as unknown[]).length} (source_id=${JSON.stringify(row.source_id ?? null)})`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Escape a path for single-quoted SQL string literals. */
|
|
130
|
+
function sqlString(value: string): string {
|
|
131
|
+
return value.replace(/'/g, "''")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Convert a labeled-row JSONL to a v0.5.0-schema Parquet shard. */
|
|
135
|
+
export async function jsonlToParquet(
|
|
136
|
+
options: JSONLToParquetOptions,
|
|
137
|
+
report?: (line: string) => void
|
|
138
|
+
): Promise<JSONLToParquetSummary> {
|
|
139
|
+
const rowGroupSize = options.rowGroupSize ?? 50000
|
|
140
|
+
|
|
141
|
+
if (!Number.isInteger(rowGroupSize) || rowGroupSize <= 0) {
|
|
142
|
+
throw new Error(`rowGroupSize must be a positive integer (got ${JSON.stringify(rowGroupSize)})`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Stage the validated rows to a temp NDJSON, then let DuckDB type + write them. Streaming keeps
|
|
146
|
+
// memory O(1) on the Node side (the Python original buffered every column into memory first). The
|
|
147
|
+
// `finally` covers the validation pass too, so a mid-stream span-triple failure leaves no orphan.
|
|
148
|
+
const stagePath = join(tmpdir(), `mw-jsonl-to-parquet-${randomUUID()}.ndjson`)
|
|
149
|
+
const stage = createWriteStream(stagePath, { encoding: "utf8" })
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
let rows = 0
|
|
153
|
+
let lineNo = 0
|
|
154
|
+
|
|
155
|
+
// TextSpliterator, not JSONSpliterator: the staging write below streams the RAW line bytes to
|
|
156
|
+
// DuckDB verbatim (JSON.parse here only validates), so a re-serialized JSONSpliterator row would
|
|
157
|
+
// defeat the point. CRLF is handled by the existing `rawLine.trim()` (strips a trailing \r),
|
|
158
|
+
// same as readline's crlfDelay:Infinity did.
|
|
159
|
+
for await (const rawLine of TextSpliterator.fromAsync(options.input)) {
|
|
160
|
+
lineNo++
|
|
161
|
+
const line = rawLine.trim()
|
|
162
|
+
|
|
163
|
+
if (!line) continue
|
|
164
|
+
const row = JSON.parse(line) as Record<string, unknown>
|
|
165
|
+
assertSpanTriple(row, lineNo)
|
|
166
|
+
// Write the validated line verbatim; DuckDB's `read_json` projects to the explicit `columns`
|
|
167
|
+
// map below (extra keys dropped, absent keys → NULL — matching the Python `row.get(c)`).
|
|
168
|
+
stage.write(line + "\n")
|
|
169
|
+
rows++
|
|
170
|
+
}
|
|
171
|
+
await new Promise<void>((resolve, reject) => stage.end((err?: Error | null) => (err ? reject(err) : resolve())))
|
|
172
|
+
|
|
173
|
+
report?.(`Read ${rows} rows from ${options.input}`)
|
|
174
|
+
|
|
175
|
+
const columnsLiteral = "{" + REQUIRED_COLUMNS.map((c) => `'${c}': '${COLUMN_TYPES[c]}'`).join(", ") + "}"
|
|
176
|
+
const selectList = REQUIRED_COLUMNS.join(", ")
|
|
177
|
+
|
|
178
|
+
// @duckdb/node-api is an optional peer — lazy import (the pipeline convention).
|
|
179
|
+
const { DuckDBInstance } = await import("@duckdb/node-api")
|
|
180
|
+
const instance = await DuckDBInstance.create()
|
|
181
|
+
const db = await instance.connect()
|
|
182
|
+
// Row order is load-bearing: the overlay-manifest assembler records first/last source_id from
|
|
183
|
+
// shard order. `preserve_insertion_order` (DuckDB default) keeps output order = input order.
|
|
184
|
+
await db.run("SET preserve_insertion_order=true")
|
|
185
|
+
await db.run(
|
|
186
|
+
`COPY (SELECT ${selectList} FROM read_json('${sqlString(stagePath)}', ` +
|
|
187
|
+
`columns = ${columnsLiteral}, format = 'newline_delimited')) ` +
|
|
188
|
+
`TO '${sqlString(options.output)}' (FORMAT PARQUET, COMPRESSION SNAPPY, ROW_GROUP_SIZE ${rowGroupSize})`
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const counted = await db.runAndReadAll(`SELECT count(*) AS n FROM read_parquet('${sqlString(options.output)}')`)
|
|
192
|
+
const written = Number(counted.getRowObjects()[0]!.n)
|
|
193
|
+
report?.(`Wrote ${written} rows to ${options.output}`)
|
|
194
|
+
|
|
195
|
+
return { read: rows, written, outPath: options.output }
|
|
196
|
+
} finally {
|
|
197
|
+
stage.destroy()
|
|
198
|
+
await unlink(stagePath).catch(() => {})
|
|
199
|
+
}
|
|
200
|
+
}
|