Migrate node:readline → spliterator
Status: EXECUTED 2026-07-08 — all sites migrated; see "Execution notes" at the end for the
deltas between this plan and what the v3.1.0 API audit + migration actually found.
Scope: 25 files / 27 call sites
Goal: Replace node:readline createInterface line-by-line streaming with
spliterator's TextSpliterator, JSONSpliterator, or CSVSpliterator — the same
library already used in core/resources/, registry/ingest.ts, and
mailwoman/gazetteer-pipeline/.
Motivation
spliterator is the monorepo's idiomatic streaming-I/O layerlayerOne transformer block — attention plus a feed-forward network, with normalization and residual connections — applied to every position. Stacking layers lets the model build up richer representations; Mailwoman's encoder has 6.. It delivers the same
result as node:readline while adding:
- Lower allocation pressure.
readlinedecodes every line into a V8 string before yielding it.TextSpliteratoroperates on byte ranges and decodes once at the consumer — large JSONL files spend less time in GC. - Deterministic file-handle lifecycle.
readline'scloseevent fires asynchronously after the stream ends; an earlybreakout of afor awaitcan leave the fd open until GC runs (Node 24+ warns).AsyncSpliteratorhas an explicit[Symbol.asyncDispose]()lifecycle, andautoDispose: false+ caller-owned handles (theregistry/ingest.tspattern) give full control. - Subclass specialization.
CSVSpliterator/JSONSpliterator/TSVSpliteratoreach eliminate the per-lineJSON.parse/splitboilerplate that cluttersreadlineloops. - Single dependency surface. Every workspace in the caller set already depends on
spliterator(it's a dependency of@mailwoman/core,@mailwoman/corpus,mailwoman, and@mailwoman/registry).
Call-site inventory
| File | Pattern | Spliterator replacement |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----- |
| corpus/scripts/build-kryptonite-shard.ts:87 | createInterface({ input: createReadStream(jsonl, "utf8"), crlfDelay: Infinity }) | TextSpliterator.fromAsync(jsonl) |
| corpus/scripts/build-transliteration-shard.ts:116 | same shape | TextSpliterator.fromAsync(jsonl) |
| corpus/scripts/ingest-csv.ts:206,311 | CSV ingest — two passes | CSVSpliterator.fromAsync(path, { mode: "array" }) ⚠️ |
| corpus/src/build.ts:338 | streamJsonl<T> helper | JSONSpliterator.fromAsync<T>(path) |
| corpus/src/split.ts:219 | shuffle-split of labeled JSONL | JSONSpliterator.fromAsync(labeledJsonlPath) (read phasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). only; write needs createWriteStream) |
| corpus/src/adapters/gnaf/adapter.ts:81 | pipe-delimited G-NAFG-NAF (Geocoded National Address File). Australia's authoritative open address register (CC-BY-licensed), used as a training source for Australian addresses. | TSVSpliterator.fromAsync with delimiter: " | " |
| corpus/src/adapters/openaddresses/adapter.ts:142 | OA GeoJSONL | JSONSpliterator.fromAsync(adapterOpts.inputPath) |
| corpus/src/adapters/overture/adapter.ts:81 | Overture JSONL | JSONSpliterator.fromAsync(opts.inputPath) |
| corpus/src/adapters/synth-po-box/adapter.ts:94 | synthetic PO boxPO boxA numbered mailbox at a post office used as a delivery address instead of a physical street location. Mailwoman tags it as the po_box component; structurally the same family as a subpremise. JSONL | JSONSpliterator.fromAsync(options.inputPath) |
| corpus/src/adapters/usgov-nad/adapter.ts:252 | NADNAD (National Address Database). A US Department of Transportation dataset of structured address points, added to the training corpus as a major source of real US addresses. GeoJSON | JSONSpliterator.fromAsync(join(opts.inputPath, shard)) |
| corpus/src/shard-recipes/fr-admin-split.ts:66 | readCommunes CSV | CSVSpliterator.fromAsync(path, { mode: "object" }) |
| corpus/src/shard-recipes/locale.ts:220 | OA CSV reservoir sample | CSVSpliterator.fromAsync(input, { mode: "array" }) |
| corpus/src/shard-recipes/scaffold.ts:71 | tuple JSONL → ShardTuple | JSONSpliterator.fromAsync<ShardTuple>(input) |
| mailwoman/commands/gazetteer/importance.tsx:131 | gzipped TSV via createInterface({ input: fileStream.pipe(gunzip) }) | TextSpliterator.fromAsync(fileStream.pipe(gunzip), { delimiter: Delimiters.Tab }) |
| mailwoman/commands/gazetteer/postcode-intl.tsx:89 | GeoNamesGeoNamesA free global gazetteer combining administrative, postal, and POI data across 200+ countries. Supplements Who's On First for postcode centroids and places where WOF has gaps. TSV | TSVSpliterator.fromAsync(file) |
| mailwoman/corpus-tools/align-shard.ts:40 | canonicalize JSONL + rewrite | JSONSpliterator.fromAsync(args.input) (read phasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary).) |
| scripts/jsonl-to-parquet.ts:159 | validate JSONL → DuckDB | JSONSpliterator.fromAsync(args.input) |
| scripts/eval/audit-po-box-cedex-shard.ts:156 | JSONL audit | JSONSpliterator.fromAsync(opts.input) |
| scripts/eval/reverse-geocode-eval.ts:105 | JSONL evalevalRunning the model against a held-out golden dataset and computing per-component F1, exact-match, calibration, and resolved-coordinate error. rows | JSONSpliterator.fromAsync(args.eval) |
| scripts/eval/gauntlet/holdout.ts:101 | JSONL reservoir sample | JSONSpliterator.fromAsync(src.file) |
| scripts/eval/record-matcher/train-cross-gbt.ts:146 | CSV with manual quote handling | CSVSpliterator.fromAsync(path, { mode: "object", enableQuoteHandling: true }) |
| scripts/eval/record-matcher/train-org-cross-gbt.ts:129 | same pattern | same |
| tiger/sdk/fetch.ts:310 | ogr2ogr stdout (GeoJSON per line) | JSONSpliterator.fromAsync(child.stdout) ⚠️ |
| tiger/sdk/redistricting.ts:122,203 | pipe-delimited census files | TSVSpliterator.fromAsync(path, { delimiter: " | " }) |
| osm/sdk/extract.ts:127 | osmconvert stdout | TextSpliterator.fromAsync(proc.stdout) |
| osm/sdk/street-recovery.ts:126 | osmconvert stdout | TextSpliterator.fromAsync(proc.stdout) |
⚠️ denotes a call site that warrants extra care (see "Risks" below).
Intentionally kept:
| File | Why |
|---|---|
scripts/bless-package.ts:40 | Interactive TTY prompt — readline/promises question() is the correct API. Spliterator is a streaming byte-range splitter, not an interactive I/O layerlayerOne transformer block — attention plus a feed-forward network, with normalization and residual connections — applied to every position. Stacking layers lets the model build up richer representations; Mailwoman's encoder has 6.. |
Migration recipe by pattern
Pattern A: JSONL (for await (const line of rl) + JSON.parse)
Before:
import { createReadStream } from "node:fs"
import { createInterface } from "node:readline"
const rl = createInterface({ input: createReadStream(path, { encoding: "utf8" }), crlfDelay: Infinity })
for await (const line of rl) {
const row = JSON.parse(line)
// ...
}
After:
import { JSONSpliterator } from "spliterator"
for await (const row of JSONSpliterator.fromAsync<MyType>(path)) {
// row is already parsed — no JSON.parse needed
}
Affected files (11): build.ts, split.ts, openaddresses/adapter.ts,
overture/adapter.ts, synth-po-box/adapter.ts, usgov-nad/adapter.ts,
scaffold.ts, align-shard.ts, jsonl-to-parquet.ts,
audit-po-box-cedex-shard.ts, reverse-geocode-eval.ts, holdout.ts
Pattern B: CSV (for await (const line …) + line.split(",") or manual split)
Before:
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity })
let header: string[] | null = null
for await (const line of rl) {
const fields = line.split(",")
if (!header) {
header = fields
continue
}
const row: Record<string, string> = {}
for (let i = 0; i < header.length; i++) row[header[i]] = fields[i]
}
After:
import { CSVSpliterator } from "spliterator"
for await (const row of CSVSpliterator.fromAsync(path, { mode: "object" })) {
// row is Record<string, string> with header keys
}
RESOLVED in v3.1.0: the column tokenizertokenizerThe component that converts a raw address string into a sequence of numeric token IDs the model can process. Mailwoman's tokenizer is a SentencePiece unigram model trained specifically on postal addresses. now unconditionally preserves empty fields (
skipEmpty: falseinternally; probed:"a,,c,"keeps all 4 columns including trailing empties). Theregistry/ingest.ts:54workaround comment is stale on this point. ⚠️ NEW known issue found during execution:enableQuoteHandling: truedoes NOT protect embedded delimiters inside quoted fields — the option is applied to row splitting only and never reaches the column tokenizertokenizerThe component that converts a raw address string into a sequence of numeric token IDs the model can process. Mailwoman's tokenizer is a SentencePiece unigram model trained specifically on postal addresses. (probed:"a,x",bmis-parsesaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. into two rows). Any CSV with quoted fields must keep manual quote handling overTextSpliterator. Upstream fix needed.
Affected files (3): fr-admin-split.ts ⚠️, locale.ts ⚠️,
train-cross-gbt.ts, train-org-cross-gbt.ts
ForSuperseded during execution:train-cross-gbt.tsandtrain-org-cross-gbt.ts, the current code has manual quote-handling withpendingbuffers —CSVSpliteratorwithenableQuoteHandling: truereplaces all of that.enableQuoteHandlingis broken for embedded delimiters (see the resolved/new-issue note under Pattern B). Both trainers kept their manual quote/pending logic; only the line-reading layerlayerOne transformer block — attention plus a feed-forward network, with normalization and residual connections — applied to every position. Stacking layers lets the model build up richer representations; Mailwoman's encoder has 6. moved toTextSpliterator, with a\rstrip (the CMS hospital CSV is CRLF — load-bearing, see Execution notes).
Pattern C: Pipe/Custom delimiter (for await (const line …) + line.split("|") or "\t")
Before:
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity })
for await (const line of rl) {
const fields = line.split("|")
}
After:
import { CSVSpliterator, Delimiters } from "spliterator"
// CORRECTED during execution: `delimiter` is the ROW delimiter (leave it default LF).
// The column separator option is `columnDelimiter`. Also note `header: true` is the
// DEFAULT — row 1 is consumed as a header even in mode:"array" — so headerless files
// (GeoNames, census) need `header: false` or they silently lose their first row.
for await (const fields of CSVSpliterator.fromAsync(path, {
mode: "array",
columnDelimiter: Delimiters.Pipe,
header: false,
})) {
// fields is string[]
}
Affected files (4): gnaf/adapter.ts, importance.tsx,
redistricting.ts:122,203, postcode-intl.tsx
Pattern D: Child-process stdout
Before:
const proc = spawn("ogr2ogr", [...])
const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity })
for await (const line of rl) {
const feat = JSON.parse(line)
}
After:
import { JSONSpliterator } from "spliterator"
const proc = spawn("ogr2ogr", [...])
for await (const feat of JSONSpliterator.fromAsync(proc.stdout!)) {
// feat is already parsed
}
spliterator accepts
AsyncChunkIterator(which is whatReadable.toWeb()or aReadablepassed directly yields — the same interfacechild_processstdout satisfies as an async iterable of chunksunknown tokenA placeholder for input not in the tokenizer's vocabulary. Byte fallback largely removes the need for one by encoding unseen characters as raw bytes instead.). ⚠️ Test this path carefully —ogr2ograndosmconvertsometimes emit trailing data after the stream appears done.
Affected files (4): tiger/sdk/fetch.ts, osm/sdk/extract.ts,
osm/sdk/street-recovery.ts, locale.ts:218 (unzip pipe)
Pattern E: Plain text line reader (no per-line parse)
Before:
const rl = createInterface({ input: createReadStream(jsonl, "utf8"), crlfDelay: Infinity })
for await (const line of rl) {
/* process raw line */
}
After:
import { TextSpliterator } from "spliterator"
for await (const line of TextSpliterator.fromAsync(jsonl)) {
// line is a string — same as readline
}
Affected files (2): build-kryptonite-shard.ts, build-transliteration-shard.ts
Migration order
| PhasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). | Files | Notes |
|---|---|---|
| 1 | scripts/jsonl-to-parquet.ts, corpus/src/build.ts (JSONL → JSONSpliterator) | Lowest risk — JSON has no delimiter ambiguity. These are the most exercised paths (every corpuscorpusThe BIO-labeled training data used to train Mailwoman's neural classifier. Assembled from real sources (OpenAddresses, National Address Database) and synthetic shards (boundary stress, order variants, negative space). Managed by @mailwoman/corpus. build hits them). |
| 2 | All adapter JSONL call sites: openaddresses/adapter.ts, overture/adapter.ts, synth-po-box/adapter.ts, usgov-nad/adapter.ts, gnaf/adapter.ts, scaffold.ts, align-shard.ts | All JSONL. Same shape as PhasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). 1. |
| 3 | scripts/eval/ call sites: audit-po-box-cedex-shard.ts, reverse-geocode-eval.ts, holdout.ts, train-cross-gbt.ts, train-org-cross-gbt.ts | JSONL + CSV. Eval scripts — low blast radius for regressions. |
| 4 | CSV sites: fr-admin-split.ts, locale.ts, ingest-csv.ts | ⚠️ skipEmpty caveat — verify column counts match before landing. |
| 5 | Pipe/TSV sites: importance.tsx, postcode-intl.tsx, redistricting.ts, build-kryptonite-shard.ts, build-transliteration-shard.ts | Straightforward delimiter substitution. |
| 6 | Child-process stdout sites: tiger/sdk/fetch.ts, osm/sdk/extract.ts, osm/sdk/street-recovery.ts, locale.ts:218 | ⚠️ Highest risk — external process pipelinesstaged pipelineMailwoman's runtime architecture: a sequence of pure-function stages (normalize → query-shape → locale-gate → kind-classifier → phrase-grouper → classifier → decoder) connected by typed handoffs. Each stage is published as its own npm package.. Test with real data. |
Risks
-
CSVSpliteratorskipEmptybug. Empty trailing fields are dropped. Any CSV with sparse fixed-width columns (NPPES, NADNAD (National Address Database). A US Department of Transportation dataset of structured address points, added to the training corpus as a major source of real US addresses., census files) will mis-align. Theregistry/ingest.tsworkaround (manualsplitafterTextSpliterator) is the correct mitigation until spliterator is patched. AUDIT affected call sites before migrating. -
Child-process backpressure.
readlinepauses the underlying stream when it can't keep up.TextSpliterator/JSONSpliteratoruse the samefor awaitpull modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' — backpressure is identical. The risk is a stream teardown race:readlinefirescloseafter the stream ends;AsyncSpliteratordisposes immediately. If the child process writes trailing data after the main payload, spliterator may miss it. Test with realogr2ogr/osmconvertoutput. -
createInterfacewithprocess.stdin.scripts/bless-package.tsusesreadline/promisesfor interactive OTP prompting. Spliterator is a byte-range splitter, not a line-editor. This call site is intentionally excluded from the migration. -
split.tswrite path.corpus/src/split.ts:219usescreateInterfacefor reading ANDcreateWriteStreamfor writing. Only the read phasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary). migrates to spliterator. The write path stays onnode:fsstreams — spliterator'swritermodule is a different concern and this migration does not touch it.
Validation
For each phasephaseA milestone in the implementation plan (Foundation, Corpus, Training, Integration, and forward-looking phases). Distinct from stage (runtime pipeline) and tier (model vocabulary)., verify:
- JSONL: byte-identical output for a known fixture (same row count, same values).
- CSV: column count matches the pre-migration baseline on a known fixture.
- Pipe/TSV: field count and values match.
- Child-process: exit code + row count match the
readlinebaseline.
Execution notes (2026-07-08)
Executed by five parallel agents over disjoint file sets, against a pre-flight API audit of the installed spliterator v3.1.0 (live probes, not docs). Everything above marked "corrected" or "superseded" came out of that audit. The deltas that mattered:
API facts the plan missed (probed)
header: trueis the default in every CSV/TSV mode — evenmode: "array"consumes row 1. Headerless sites (fr-admin-split.tscommunes TSV,postcode-intl.tsxGeoNamesGeoNamesA free global gazetteer combining administrative, postal, and POI data across 200+ countries. Supplements Who's On First for postcode centroids and places where WOF has gaps.) tookheader: false; without it each would have silently dropped its first record.delimiter≠ column separator.delimitersplits ROWS;columnDelimitersplits columns.- CRLF is not normalized (readline's
crlfDelay: Infinitywas). JSONL sites are immune (\ris JSON whitespace). Text/CSV sites got a per-file disposition; the live case:cms-pos_hospital-other_2026q1.csvIS CRLF, and without a\rstrip the trainer's last column (ZIP_CD) mis-keys.importance.tsx(remote nominatim gz) strips defensively — its wikidata id is the last column. AsyncDataResourceomitsAsyncChunkIteratorin the published type although its own docstring lists it and the runtime dispatches onSymbol.asyncIterator. Stream call sites (child stdout, gunzip/unzip pipes) useas unknown as AsyncDataResourcewith a comment.- String streams silently break the byte scanner:
createReadStream(path, { encoding: "utf8" })yields string chunksunknown tokenA placeholder for input not in the tokenizer's vocabulary. Byte fallback largely removes the need for one by encoding unseen characters as raw bytes instead. the splitter can't scan (probed: all offsets -1).locale.tshad exactly this; the encoding option was removed so the stream yields Buffers. - Tolerance parity ruled most JSONL sites.
JSONSpliteratorthrows on the first malformed row; every adapter/stream site that deliberately skipped bad lines (OA#comments, non-Featureshapes, ogr2ogr noise) stayed onTextSpliterator+ its existing try/catch.JSONSpliteratorlanded only where the original was fail-loud (build.tsstreamJsonl,split.ts, the two evalevalRunning the model against a held-out golden dataset and computing per-component F1, exact-match, calibration, and resolved-coordinate error. readers).
Inventory corrections
corpus/src/adapters/gnaf/adapter.tsis a JSONL reader, not pipe-delimited — the PSV reader isassemble.ts, which already usedPSVSpliteratorbefore this migration.scripts/eval/gauntlet/holdout.tsis semicolon-delimited CSV, not JSONL.
The doc's stated risks, resolved
- Trailing child-process data (risk 2): refuted. Synthetic harness — 100k JSONL lines, flush, 150 ms sleep, 3 more lines — read 100,003/100,003 via spliterator-over-stdout, identical to the readline baseline, exit code observed. Early break after 1k lines: clean disposal, no fd warnings, child killable/awaitable as before.
- skipEmpty (risk 1): fixed upstream in v3.1.0 (empty fields preserved; probed).
Performance (the standing 2026-06-10 benchmark, re-run)
Same protocol (labeled-train.jsonl v0.1.1, 21.8M rows / 11 GB, JSON.parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. both arms, checksummed, warm cache, Node 26.2.0): spliterator v2 measured 0.45× readline; v3.1.0 measures 0.91–0.92× (readline 0.52–0.53M rows/s, spliterator 0.48M rows/s, checksums identical). The rewrite roughly doubled v2's throughput; the residual ~8% deficit is the price of the fd-lifecycle
- dependency-surface wins, not a blocker.
Upstream spliterator issues to file
enableQuoteHandlingnever reaches the column tokenizertokenizerThe component that converts a raw address string into a sequence of numeric token IDs the model can process. Mailwoman's tokenizer is a SentencePiece unigram model trained specifically on postal addresses. — quoted embedded delimiters mis-parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates..AsyncDataResourceshould includeAsyncChunkIterator(docstring already claims it).- Consider a
crlf: true(or default) row-delimiter normalization to match readline ergonomics. - Consider rejecting or decoding string-chunk streams instead of silently failing to match.
Follow-up
registry/ingest.ts:54's workaround comment cites the fixed skipEmpty bug as its reason — its real remaining reason is the quote-handling gap (issue 1). Update the comment when filing.corpus/scripts/ingest-csv.tshad a pre-existing launcher-breakingimport { SQLInputValue }(value import of a type under type-stripping) — fixed toimport typeduring integration.