@juspay/neurolink 9.94.1 → 9.94.3
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/CHANGELOG.md +12 -0
- package/dist/agent/directTools.js +3 -1
- package/dist/browser/neurolink.min.js +404 -403
- package/dist/cli/factories/commandFactory.d.ts +6 -0
- package/dist/cli/factories/commandFactory.js +36 -8
- package/dist/lib/agent/directTools.js +3 -1
- package/dist/lib/neurolink.js +5 -0
- package/dist/lib/rag/document/loaders.d.ts +12 -1
- package/dist/lib/rag/document/loaders.js +64 -34
- package/dist/lib/types/file.d.ts +43 -0
- package/dist/lib/types/generate.d.ts +3 -11
- package/dist/lib/types/rag.d.ts +7 -0
- package/dist/lib/types/stream.d.ts +2 -6
- package/dist/lib/utils/csvProcessor.d.ts +121 -5
- package/dist/lib/utils/csvProcessor.js +563 -114
- package/dist/lib/utils/csvUtils.d.ts +26 -0
- package/dist/lib/utils/csvUtils.js +85 -0
- package/dist/lib/utils/errorHandling.d.ts +23 -0
- package/dist/lib/utils/errorHandling.js +65 -0
- package/dist/lib/utils/multimodalOptionsBuilder.d.ts +1 -5
- package/dist/lib/utils/textEncoding.d.ts +25 -0
- package/dist/lib/utils/textEncoding.js +141 -0
- package/dist/neurolink.js +5 -0
- package/dist/rag/document/loaders.d.ts +12 -1
- package/dist/rag/document/loaders.js +64 -34
- package/dist/types/file.d.ts +43 -0
- package/dist/types/generate.d.ts +3 -11
- package/dist/types/rag.d.ts +7 -0
- package/dist/types/stream.d.ts +2 -6
- package/dist/utils/csvProcessor.d.ts +121 -5
- package/dist/utils/csvProcessor.js +562 -113
- package/dist/utils/csvUtils.d.ts +26 -0
- package/dist/utils/csvUtils.js +84 -0
- package/dist/utils/errorHandling.d.ts +23 -0
- package/dist/utils/errorHandling.js +65 -0
- package/dist/utils/multimodalOptionsBuilder.d.ts +1 -5
- package/dist/utils/textEncoding.d.ts +25 -0
- package/dist/utils/textEncoding.js +140 -0
- package/package.json +3 -1
|
@@ -4,8 +4,31 @@
|
|
|
4
4
|
* Uses streaming for memory efficiency with large files
|
|
5
5
|
*/
|
|
6
6
|
import csvParser from "csv-parser";
|
|
7
|
-
import
|
|
7
|
+
import iconv from "iconv-lite";
|
|
8
|
+
import { Readable, Transform } from "stream";
|
|
9
|
+
import { extname } from "path";
|
|
8
10
|
import { logger } from "./logger.js";
|
|
11
|
+
import { splitCSVFields, detectDelimiter as detectDelimiterUtil, CANDIDATE_DELIMITERS, } from "./csvUtils.js";
|
|
12
|
+
import { isObject } from "./typeUtils.js";
|
|
13
|
+
import { decodeBuffer } from "./textEncoding.js";
|
|
14
|
+
import { ErrorFactory } from "./errorHandling.js";
|
|
15
|
+
import { withTimeout, TimeoutError } from "./async/withTimeout.js";
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Parse Safety Limits (#371 / #379)
|
|
18
|
+
// ============================================================================
|
|
19
|
+
/**
|
|
20
|
+
* Upper bound on a single CSV row's byte size (#371). csv-parser defaults to an
|
|
21
|
+
* effectively-unbounded `maxRowBytes`; a pathological unbroken row would grow
|
|
22
|
+
* memory without limit and never fire the parser's own error path. 10 MB/row is
|
|
23
|
+
* generous for legitimate data (e.g. a large JSON blob in one cell) while
|
|
24
|
+
* bounding a single allocation and making the parser-error cleanup path
|
|
25
|
+
* reachable/testable.
|
|
26
|
+
*/
|
|
27
|
+
const CSV_MAX_ROW_BYTES = 10 * 1024 * 1024;
|
|
28
|
+
/** Default wall-clock cap for `parseCSVString` (#379). */
|
|
29
|
+
const DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS = 30_000;
|
|
30
|
+
/** Default wall-clock cap for `parseCSVFile` (#379). */
|
|
31
|
+
const DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS = 300_000;
|
|
9
32
|
// ============================================================================
|
|
10
33
|
// Data Type Detection Patterns
|
|
11
34
|
// ============================================================================
|
|
@@ -66,6 +89,131 @@ function validateColumnName(name) {
|
|
|
66
89
|
return issues;
|
|
67
90
|
}
|
|
68
91
|
// ============================================================================
|
|
92
|
+
// Column Name Sanitization (#378)
|
|
93
|
+
// ============================================================================
|
|
94
|
+
/**
|
|
95
|
+
* Rewrite a raw header into a valid identifier. Trims, replaces non-identifier
|
|
96
|
+
* runs with `_`, prefixes a `col_` when it starts with a digit or is empty,
|
|
97
|
+
* then applies the requested case style. Never returns an empty string.
|
|
98
|
+
*/
|
|
99
|
+
export function sanitizeColumnName(name, style = "snake_case", index = 0) {
|
|
100
|
+
const trimmed = (name ?? "").trim();
|
|
101
|
+
// Split on runs of non-alphanumeric characters. Using split (linear) instead
|
|
102
|
+
// of a `^_+|_+$` trim regex avoids the polynomial-backtracking ReDoS CodeQL
|
|
103
|
+
// flags on long underscore/separator runs, and drops leading/trailing/empty
|
|
104
|
+
// segments naturally.
|
|
105
|
+
let parts = trimmed.split(/[^A-Za-z0-9]+/).filter((p) => p.length > 0);
|
|
106
|
+
let base = parts.join("_");
|
|
107
|
+
if (base === "") {
|
|
108
|
+
base = `col_${index + 1}`;
|
|
109
|
+
parts = [base];
|
|
110
|
+
}
|
|
111
|
+
else if (/^[0-9]/.test(base)) {
|
|
112
|
+
base = `col_${base}`;
|
|
113
|
+
parts = base.split("_").filter((p) => p.length > 0);
|
|
114
|
+
}
|
|
115
|
+
if (style === "camelCase") {
|
|
116
|
+
return parts
|
|
117
|
+
.map((p, i) => i === 0
|
|
118
|
+
? p.charAt(0).toLowerCase() + p.slice(1)
|
|
119
|
+
: p.charAt(0).toUpperCase() + p.slice(1))
|
|
120
|
+
.join("");
|
|
121
|
+
}
|
|
122
|
+
return parts.map((p) => p.toLowerCase()).join("_");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Deduplicate a list of (already sanitized) names by suffixing `_2`, `_3`, … on
|
|
126
|
+
* collisions, preserving order. The suffixed candidate is bumped until it does
|
|
127
|
+
* not collide with ANY already-emitted name — so an existing `name_2` can't be
|
|
128
|
+
* silently overwritten by a generated `name_2` (which the naive counter did for
|
|
129
|
+
* `["name","name","name_2"]`).
|
|
130
|
+
*/
|
|
131
|
+
export function dedupeColumnNames(names) {
|
|
132
|
+
const used = new Set();
|
|
133
|
+
return names.map((name) => {
|
|
134
|
+
if (!used.has(name)) {
|
|
135
|
+
used.add(name);
|
|
136
|
+
return name;
|
|
137
|
+
}
|
|
138
|
+
let n = 2;
|
|
139
|
+
let candidate = `${name}_${n}`;
|
|
140
|
+
while (used.has(candidate)) {
|
|
141
|
+
n++;
|
|
142
|
+
candidate = `${name}_${n}`;
|
|
143
|
+
}
|
|
144
|
+
used.add(candidate);
|
|
145
|
+
return candidate;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Compute sanitized (and deduped) column names plus the original→sanitized
|
|
150
|
+
* mapping for the names that actually changed.
|
|
151
|
+
*/
|
|
152
|
+
function buildColumnNameMapping(headers, style) {
|
|
153
|
+
const sanitized = dedupeColumnNames(headers.map((h, i) => sanitizeColumnName(h, style, i)));
|
|
154
|
+
const mapping = headers
|
|
155
|
+
.map((original, i) => ({ original, sanitized: sanitized[i] }))
|
|
156
|
+
.filter((m) => m.original !== m.sanitized);
|
|
157
|
+
return { sanitized, mapping };
|
|
158
|
+
}
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Row Validation (#384)
|
|
161
|
+
// ============================================================================
|
|
162
|
+
/**
|
|
163
|
+
* Predicate form of the row-shape invariant: a non-null, non-array object whose
|
|
164
|
+
* every value is a string or undefined. csv-parser's default output always
|
|
165
|
+
* satisfies this; the guard is defense-in-depth at the parse boundary.
|
|
166
|
+
*/
|
|
167
|
+
export function isValidCsvRow(row) {
|
|
168
|
+
if (!isObject(row) || Array.isArray(row)) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return Object.values(row).every((v) => typeof v === "string" || v === undefined);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Assertion form used inside the streaming `data` handlers. Throws a
|
|
175
|
+
* `[CSVProcessor]`-prefixed error (so existing `includes("CSV")` catches still
|
|
176
|
+
* match) when a row violates the invariant.
|
|
177
|
+
*/
|
|
178
|
+
export function assertValidCsvRow(row, rowNumber) {
|
|
179
|
+
if (!isValidCsvRow(row)) {
|
|
180
|
+
throw ErrorFactory.csvRowInvalid(`[CSVProcessor] Invalid CSV row ${rowNumber}: expected a string-keyed object with string values, got ${Array.isArray(row) ? "array" : typeof row}`, rowNumber);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// Parse Error Context (#375)
|
|
185
|
+
// ============================================================================
|
|
186
|
+
/**
|
|
187
|
+
* Build an enriched, consistent parse-failure message shared by all four
|
|
188
|
+
* source/parser reject sites so they can't re-diverge.
|
|
189
|
+
*
|
|
190
|
+
* Deliberately structural-only: `headerCount` and `lastRowColumnCount` report
|
|
191
|
+
* how many columns the header/last row had — counts, never the actual header
|
|
192
|
+
* or cell string values. Parsed header text is user-controlled CSV data (and,
|
|
193
|
+
* for headerless input, may literally be first-row cell values), so it must
|
|
194
|
+
* never be embedded in a thrown/logged message (#1199).
|
|
195
|
+
*/
|
|
196
|
+
function buildCsvParseErrorMessage(info) {
|
|
197
|
+
const parts = [
|
|
198
|
+
`[CSVProcessor] CSV ${info.stage === "read" ? "file read" : "parsing"} failed after ${info.count} row(s)`,
|
|
199
|
+
];
|
|
200
|
+
if (info.location) {
|
|
201
|
+
parts.push(`(${info.location})`);
|
|
202
|
+
}
|
|
203
|
+
parts.push(`: ${info.cause}`);
|
|
204
|
+
parts.push(typeof info.headerCount === "number"
|
|
205
|
+
? `| headerCount: ${info.headerCount}`
|
|
206
|
+
: "| headerCount: unknown (failed before headers were parsed)");
|
|
207
|
+
if (typeof info.bytesRead === "number") {
|
|
208
|
+
parts.push(`| bytesRead: ${info.bytesRead}`);
|
|
209
|
+
}
|
|
210
|
+
if (typeof info.lastRowColumnCount === "number") {
|
|
211
|
+
parts.push(`| lastRow columns: ${info.lastRowColumnCount}`);
|
|
212
|
+
}
|
|
213
|
+
parts.push("| Expected RFC 4180 CSV (comma-separated, double-quote escaped)");
|
|
214
|
+
return parts.join(" ");
|
|
215
|
+
}
|
|
216
|
+
// ============================================================================
|
|
69
217
|
// Data Type Detection
|
|
70
218
|
// ============================================================================
|
|
71
219
|
/**
|
|
@@ -439,8 +587,12 @@ function detectHasHeaders(headerValues, dataRows) {
|
|
|
439
587
|
* - Lines with significantly different delimiter count than line 2
|
|
440
588
|
* - Lines that don't match CSV structure of subsequent lines
|
|
441
589
|
*/
|
|
442
|
-
/**
|
|
443
|
-
|
|
590
|
+
/**
|
|
591
|
+
* Strip a leading UTF-8 BOM (U+FEFF) if present. Exported so every entry
|
|
592
|
+
* point that sniffs raw text (including the RAG CSVLoader) normalizes BOMs
|
|
593
|
+
* the same way before metadata/delimiter detection.
|
|
594
|
+
*/
|
|
595
|
+
export function stripBom(text) {
|
|
444
596
|
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
445
597
|
}
|
|
446
598
|
/**
|
|
@@ -485,6 +637,31 @@ function isMetadataLine(lines) {
|
|
|
485
637
|
}
|
|
486
638
|
return false;
|
|
487
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* Strip a leading metadata line (e.g. Excel's `sep=;` preamble) from
|
|
642
|
+
* already-split lines, and read the delimiter it explicitly declares.
|
|
643
|
+
* Excel semantics: an explicit `sep=<char>` always wins over frequency-based
|
|
644
|
+
* detection; a metadata line without a recognized delimiter character is
|
|
645
|
+
* simply dropped from the sample without forcing a delimiter. This is the
|
|
646
|
+
* single place process(), detectDelimiter(), parseCSVFile(), and
|
|
647
|
+
* parseCSVString() all go through, so none of them detect a delimiter over
|
|
648
|
+
* the raw, un-stripped lines (which lets the preamble skew detection).
|
|
649
|
+
* Exported so non-CSVProcessor callers (the RAG CSVLoader) share it too.
|
|
650
|
+
*/
|
|
651
|
+
export function stripMetadataLine(lines) {
|
|
652
|
+
if (!isMetadataLine(lines)) {
|
|
653
|
+
return { dataLines: lines, hasMetadataLine: false };
|
|
654
|
+
}
|
|
655
|
+
const sepChar = lines[0]?.trim().match(/^sep=(.)/i)?.[1];
|
|
656
|
+
const explicitDelimiter = sepChar && CANDIDATE_DELIMITERS.includes(sepChar)
|
|
657
|
+
? sepChar
|
|
658
|
+
: undefined;
|
|
659
|
+
return {
|
|
660
|
+
dataLines: lines.slice(1),
|
|
661
|
+
hasMetadataLine: true,
|
|
662
|
+
explicitDelimiter,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
488
665
|
/**
|
|
489
666
|
* Split CSV text into logical rows for metadata detection and raw row limiting.
|
|
490
667
|
*
|
|
@@ -494,8 +671,12 @@ function isMetadataLine(lines) {
|
|
|
494
671
|
* `""` inside quotes is the RFC-4180 escaped quote and does not toggle the quote
|
|
495
672
|
* state. For unquoted input this yields exactly the same result as a plain
|
|
496
673
|
* `split(/\r\n|\n|\r/)`.
|
|
674
|
+
*
|
|
675
|
+
* Exported so non-CSVProcessor callers (the RAG CSVLoader) get the same
|
|
676
|
+
* quote-aware row boundaries instead of a physical `content.split("\n")`,
|
|
677
|
+
* which would break rows on a newline embedded in a quoted field.
|
|
497
678
|
*/
|
|
498
|
-
function splitCsvLines(csvString) {
|
|
679
|
+
export function splitCsvLines(csvString) {
|
|
499
680
|
const rows = [];
|
|
500
681
|
let current = "";
|
|
501
682
|
let inQuotes = false;
|
|
@@ -556,7 +737,7 @@ export class CSVProcessor {
|
|
|
556
737
|
* @returns Formatted CSV data ready for LLM (JSON or Markdown)
|
|
557
738
|
*/
|
|
558
739
|
static async process(content, options) {
|
|
559
|
-
const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, } = options || {};
|
|
740
|
+
const { maxRows: rawMaxRows = 1000, formatStyle = "raw", includeHeaders = true, sampleDataFormat = "json", extension = null, encoding, sanitizeColumnNames = false, columnNameCase = "snake_case", parseTimeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS, } = options || {};
|
|
560
741
|
const maxRows = Math.max(1, Math.min(10000, rawMaxRows));
|
|
561
742
|
logger.debug("[CSVProcessor] Starting CSV processing", {
|
|
562
743
|
contentSize: content.length,
|
|
@@ -564,21 +745,41 @@ export class CSVProcessor {
|
|
|
564
745
|
maxRows,
|
|
565
746
|
includeHeaders,
|
|
566
747
|
});
|
|
567
|
-
|
|
748
|
+
// #362: detect the encoding (BOM → chardet → UTF-8 fallback) or honor the
|
|
749
|
+
// caller's override, instead of the previous hard-coded UTF-8 decode.
|
|
750
|
+
const { text: csvString, encoding: detectedEncoding, confidence: encodingConfidence, } = decodeBuffer(content, encoding);
|
|
751
|
+
// #361: split once and strip a leading Excel `sep=` metadata line before
|
|
752
|
+
// detecting the delimiter (comma/tab/semicolon/pipe), so the preamble
|
|
753
|
+
// can't skew detection — then reuse dataLines below instead of
|
|
754
|
+
// re-splitting csvString for the raw-format row handling.
|
|
755
|
+
const lines = splitCsvLines(csvString);
|
|
756
|
+
const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
|
|
757
|
+
const delimiter = explicitDelimiter ??
|
|
758
|
+
detectDelimiterUtil(dataLines, extension ?? undefined);
|
|
568
759
|
// For raw format, return CSV text with row limit (no parsing needed)
|
|
569
760
|
// This preserves the CSV shape while normalizing line endings for row handling.
|
|
570
761
|
if (formatStyle === "raw") {
|
|
571
|
-
const lines = splitCsvLines(csvString);
|
|
572
|
-
const hasMetadataLine = isMetadataLine(lines);
|
|
573
762
|
if (hasMetadataLine) {
|
|
574
763
|
logger.debug("[CSVProcessor] Detected metadata line, skipping first line");
|
|
575
764
|
}
|
|
576
|
-
//
|
|
577
|
-
const csvLines =
|
|
578
|
-
? lines.slice(1) // Skip metadata line
|
|
579
|
-
: lines;
|
|
765
|
+
// dataLines already has the metadata line (if any) stripped.
|
|
766
|
+
const csvLines = dataLines;
|
|
580
767
|
const limitedLines = csvLines.slice(0, 1 + maxRows); // header + data rows
|
|
768
|
+
// #378: opt-in — rewrite the literal header line with sanitized,
|
|
769
|
+
// deduped identifiers so the raw CSV text shown to the LLM has clean
|
|
770
|
+
// column names too. (Splits on commas to match the existing raw-branch
|
|
771
|
+
// columnCount logic.)
|
|
772
|
+
let rawColumnNameMapping;
|
|
773
|
+
if (sanitizeColumnNames && limitedLines.length > 0) {
|
|
774
|
+
const headerCols = splitCSVFields(limitedLines[0] || "", delimiter);
|
|
775
|
+
const { sanitized, mapping } = buildColumnNameMapping(headerCols, columnNameCase);
|
|
776
|
+
limitedLines[0] = sanitized.join(delimiter);
|
|
777
|
+
rawColumnNameMapping = mapping.length > 0 ? mapping : undefined;
|
|
778
|
+
}
|
|
581
779
|
const limitedCSV = limitedLines.join("\n");
|
|
780
|
+
// #1199: quote-aware, delimiter-aware split (matches the
|
|
781
|
+
// sanitizeColumnNames branch above and #1192's detected delimiter).
|
|
782
|
+
const headerFields = splitCSVFields(limitedLines[0] || "", delimiter);
|
|
582
783
|
const rowCount = limitedLines
|
|
583
784
|
.slice(1)
|
|
584
785
|
.filter((line) => line.trim() !== "").length;
|
|
@@ -597,11 +798,22 @@ export class CSVProcessor {
|
|
|
597
798
|
logger.info("[CSVProcessor] ✅ Processed CSV file", {
|
|
598
799
|
formatStyle: "raw",
|
|
599
800
|
rowCount,
|
|
600
|
-
columnCount:
|
|
801
|
+
columnCount: headerFields.length,
|
|
601
802
|
truncated: wasTruncated,
|
|
602
803
|
});
|
|
603
|
-
// Parse a sample for enhanced metadata analysis (raw format still
|
|
604
|
-
|
|
804
|
+
// Parse a sample for enhanced metadata analysis (raw format still
|
|
805
|
+
// benefits from column analysis). Reuse the already-split, already
|
|
806
|
+
// metadata-stripped `dataLines` and the already-detected `delimiter`
|
|
807
|
+
// via the shared streamParse() core instead of routing `limitedCSV`
|
|
808
|
+
// back through parseCSVStringWithMeta(), which would re-split/re-join
|
|
809
|
+
// it and re-run a redundant BOM/metadata/delimiter-detection pass.
|
|
810
|
+
const sampleRows = Math.min(rowCount, 500);
|
|
811
|
+
const { rows: sampleForAnalysis, timedOut: rawTimedOut } = await this.streamParse(Readable.from([dataLines.slice(0, 1 + sampleRows).join("\n")]), undefined, {
|
|
812
|
+
maxRows: sampleRows,
|
|
813
|
+
skipLines: 0,
|
|
814
|
+
timeoutMs: parseTimeoutMs,
|
|
815
|
+
delimiter,
|
|
816
|
+
});
|
|
605
817
|
const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(sampleForAnalysis);
|
|
606
818
|
// Log data quality summary
|
|
607
819
|
if (dataQualityWarnings.length > 0) {
|
|
@@ -619,13 +831,19 @@ export class CSVProcessor {
|
|
|
619
831
|
size: content.length,
|
|
620
832
|
rowCount,
|
|
621
833
|
totalLines: limitedLines.length,
|
|
622
|
-
columnCount:
|
|
834
|
+
columnCount: headerFields.length,
|
|
623
835
|
extension,
|
|
624
836
|
columnMetadata,
|
|
625
837
|
dataQualityWarnings,
|
|
626
838
|
dataQualityScore,
|
|
627
|
-
hasHeaders: detectHasHeaders(
|
|
628
|
-
detectedDelimiter:
|
|
839
|
+
hasHeaders: detectHasHeaders(headerFields, undefined),
|
|
840
|
+
detectedDelimiter: delimiter,
|
|
841
|
+
detectedEncoding,
|
|
842
|
+
encodingConfidence,
|
|
843
|
+
...(rawColumnNameMapping
|
|
844
|
+
? { columnNameMapping: rawColumnNameMapping }
|
|
845
|
+
: {}),
|
|
846
|
+
...(rawTimedOut ? { parseTimedOut: true } : {}),
|
|
629
847
|
},
|
|
630
848
|
};
|
|
631
849
|
}
|
|
@@ -634,9 +852,13 @@ export class CSVProcessor {
|
|
|
634
852
|
formatStyle,
|
|
635
853
|
maxRows,
|
|
636
854
|
});
|
|
637
|
-
|
|
855
|
+
// dataLines was already split and metadata-stripped above (alongside
|
|
856
|
+
// `delimiter`) — reuse both via the shared streamParse() core instead of
|
|
857
|
+
// routing csvString back through parseCSVStringWithMeta(), which would
|
|
858
|
+
// re-split it and re-run metadata/delimiter detection a second time.
|
|
859
|
+
const { rows, timedOut: structuredTimedOut } = await this.streamParse(Readable.from([dataLines.join("\n")]), undefined, { maxRows, skipLines: 0, timeoutMs: parseTimeoutMs, delimiter });
|
|
638
860
|
// Filter out empty rows (empty objects or rows with only whitespace values from blank lines)
|
|
639
|
-
const
|
|
861
|
+
const filteredRows = rows.filter((row) => {
|
|
640
862
|
if (!row || typeof row !== "object") {
|
|
641
863
|
return false;
|
|
642
864
|
}
|
|
@@ -647,11 +869,34 @@ export class CSVProcessor {
|
|
|
647
869
|
// Check if all values are empty or whitespace-only
|
|
648
870
|
return !Object.values(row).every((val) => val === "" || (typeof val === "string" && val.trim() === ""));
|
|
649
871
|
});
|
|
872
|
+
// #378: opt-in — remap each row's keys from original → sanitized identifiers.
|
|
873
|
+
let structuredColumnNameMapping;
|
|
874
|
+
const sanitizedToOriginal = new Map();
|
|
875
|
+
let nonEmptyRows = filteredRows;
|
|
876
|
+
if (sanitizeColumnNames && filteredRows.length > 0) {
|
|
877
|
+
const origHeaders = Object.keys(filteredRows[0]);
|
|
878
|
+
const { sanitized, mapping } = buildColumnNameMapping(origHeaders, columnNameCase);
|
|
879
|
+
origHeaders.forEach((original, i) => {
|
|
880
|
+
if (original !== sanitized[i]) {
|
|
881
|
+
sanitizedToOriginal.set(sanitized[i], original);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
nonEmptyRows = filteredRows.map((row) => {
|
|
885
|
+
// Defense-in-depth: a null-prototype target means an attacker-chosen
|
|
886
|
+
// header literally named "__proto__"/"constructor"/"prototype" can
|
|
887
|
+
// only ever create a harmless own property, never touch
|
|
888
|
+
// Object.prototype (#1199).
|
|
889
|
+
const out = Object.create(null);
|
|
890
|
+
origHeaders.forEach((h, i) => {
|
|
891
|
+
out[sanitized[i]] = row[h];
|
|
892
|
+
});
|
|
893
|
+
return out;
|
|
894
|
+
});
|
|
895
|
+
structuredColumnNameMapping = mapping.length > 0 ? mapping : undefined;
|
|
896
|
+
}
|
|
650
897
|
// Extract metadata from parsed results
|
|
651
898
|
const rowCount = nonEmptyRows.length;
|
|
652
|
-
const columnNames = nonEmptyRows.length > 0
|
|
653
|
-
? Object.keys(nonEmptyRows[0])
|
|
654
|
-
: [];
|
|
899
|
+
const columnNames = nonEmptyRows.length > 0 ? Object.keys(nonEmptyRows[0]) : [];
|
|
655
900
|
const columnCount = columnNames.length;
|
|
656
901
|
const hasEmptyColumns = columnNames.some((col) => !col || col.trim() === "");
|
|
657
902
|
const sampleRows = nonEmptyRows.slice(0, 3);
|
|
@@ -666,6 +911,15 @@ export class CSVProcessor {
|
|
|
666
911
|
}
|
|
667
912
|
// Perform enhanced column analysis
|
|
668
913
|
const { columnMetadata, dataQualityWarnings, dataQualityScore } = analyzeColumns(nonEmptyRows);
|
|
914
|
+
// #378: carry the pre-sanitization header on each renamed column.
|
|
915
|
+
if (sanitizedToOriginal.size > 0) {
|
|
916
|
+
for (const col of columnMetadata) {
|
|
917
|
+
const original = sanitizedToOriginal.get(col.name);
|
|
918
|
+
if (original) {
|
|
919
|
+
col.originalName = original;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
669
923
|
// Log data quality summary
|
|
670
924
|
if (dataQualityWarnings.length > 0) {
|
|
671
925
|
logger.debug("[CSVProcessor] Data quality warnings detected", {
|
|
@@ -701,14 +955,28 @@ export class CSVProcessor {
|
|
|
701
955
|
dataQualityWarnings,
|
|
702
956
|
dataQualityScore,
|
|
703
957
|
hasHeaders: detectHasHeaders(columnNames, nonEmptyRows),
|
|
704
|
-
detectedDelimiter:
|
|
958
|
+
detectedDelimiter: delimiter,
|
|
959
|
+
detectedEncoding,
|
|
960
|
+
encodingConfidence,
|
|
961
|
+
...(structuredColumnNameMapping
|
|
962
|
+
? { columnNameMapping: structuredColumnNameMapping }
|
|
963
|
+
: {}),
|
|
964
|
+
...(structuredTimedOut ? { parseTimedOut: true } : {}),
|
|
705
965
|
},
|
|
706
966
|
};
|
|
707
967
|
}
|
|
708
968
|
/**
|
|
709
|
-
*
|
|
710
|
-
*
|
|
969
|
+
* Detect the delimiter of DSV content (comma/tab/semicolon/pipe), respecting
|
|
970
|
+
* RFC-4180 quoting. An optional extension hint (`tsv` → tab, `psv` → pipe)
|
|
971
|
+
* breaks ambiguity. Used by callers (e.g. the RAG CSV loader) that parse
|
|
972
|
+
* DSV content themselves and need the same detection (#361). A leading
|
|
973
|
+
* Excel `sep=` metadata line is stripped before sampling so it can't skew
|
|
974
|
+
* detection, and an explicit `sep=<char>` value wins outright.
|
|
711
975
|
*/
|
|
976
|
+
static detectDelimiter(csvString, extensionHint) {
|
|
977
|
+
const { dataLines, explicitDelimiter } = stripMetadataLine(splitCsvLines(stripBom(csvString)));
|
|
978
|
+
return explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
|
|
979
|
+
}
|
|
712
980
|
/**
|
|
713
981
|
* Parse CSV file from disk using streaming (memory efficient)
|
|
714
982
|
*
|
|
@@ -716,83 +984,158 @@ export class CSVProcessor {
|
|
|
716
984
|
* @param maxRows - Maximum rows to parse (default: 1000)
|
|
717
985
|
* @returns Array of row objects
|
|
718
986
|
*/
|
|
719
|
-
static async parseCSVFile(filePath, maxRows = 1000) {
|
|
987
|
+
static async parseCSVFile(filePath, maxRows = 1000, timeoutMs = DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS) {
|
|
988
|
+
const { rows } = await this.parseCSVFileWithMeta(filePath, maxRows, timeoutMs);
|
|
989
|
+
return rows;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* File-parse variant that also reports whether the parse timed out, used by
|
|
993
|
+
* `process()` to surface `metadata.parseTimedOut`.
|
|
994
|
+
*/
|
|
995
|
+
static async parseCSVFileWithMeta(filePath, maxRows = 1000, timeoutMs = DEFAULT_CSV_FILE_PARSE_TIMEOUT_MS, encoding) {
|
|
720
996
|
if (typeof filePath !== "string" || filePath.trim().length === 0) {
|
|
721
|
-
throw
|
|
997
|
+
throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVFile: filePath must be a non-empty string");
|
|
722
998
|
}
|
|
723
999
|
const clampedMaxRows = Math.max(1, Math.min(10000, maxRows));
|
|
724
1000
|
const fs = await import("fs");
|
|
1001
|
+
// #1199: a single wall-clock deadline covers access() + prepareFileSource()
|
|
1002
|
+
// + streamParse() together, not just the streamParse phase — otherwise a
|
|
1003
|
+
// stalled disk/network filesystem during the open/peek steps defeats the
|
|
1004
|
+
// #379 timeout guard entirely.
|
|
1005
|
+
const startTime = Date.now();
|
|
1006
|
+
const remaining = () => Math.max(0, timeoutMs - (Date.now() - startTime));
|
|
725
1007
|
logger.debug("[CSVProcessor] Starting file parsing", {
|
|
726
1008
|
filePath,
|
|
727
1009
|
maxRows: clampedMaxRows,
|
|
728
1010
|
});
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
if (lines.length >= 2) {
|
|
742
|
-
firstLines.push(lines[0], lines[1]);
|
|
743
|
-
lineReader.destroy();
|
|
744
|
-
resolve();
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
lineReader.on("end", () => resolve());
|
|
748
|
-
// Metadata sniffing is best-effort — a read error here must not crash.
|
|
749
|
-
lineReader.on("error", () => resolve());
|
|
750
|
-
});
|
|
751
|
-
await fileHandle.close();
|
|
752
|
-
const hasMetadataLine = isMetadataLine(firstLines);
|
|
753
|
-
const skipLines = hasMetadataLine ? 1 : 0;
|
|
754
|
-
if (hasMetadataLine) {
|
|
755
|
-
logger.debug("[CSVProcessor] Detected metadata line in file, will skip first line");
|
|
1011
|
+
// #375: a bad path (ENOENT/EACCES) should fail fast with clear context
|
|
1012
|
+
// instead of a raw Node error surfacing from the stream. access() is a
|
|
1013
|
+
// permission check, not a content read, so #368's single-read contract
|
|
1014
|
+
// (one createReadStream, no second full pass) is preserved. Metadata-line
|
|
1015
|
+
// and delimiter (#361) detection happen inside prepareFileSource() below,
|
|
1016
|
+
// from the same peeked bytes used for encoding detection — not a second
|
|
1017
|
+
// read of the file.
|
|
1018
|
+
try {
|
|
1019
|
+
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
throw ErrorFactory.csvFileAccessFailed(`[CSVProcessor] Failed to open CSV file (${filePath}): ${error instanceof Error ? error.message : String(error)}`, filePath, error instanceof Error ? error : undefined);
|
|
756
1023
|
}
|
|
1024
|
+
// #368: a SINGLE createReadStream. We peek its leading bytes to sniff the
|
|
1025
|
+
// encoding (#362) and detect a metadata line + delimiter (#361), then
|
|
1026
|
+
// replay those bytes back onto the same stream — no second disk read.
|
|
1027
|
+
const rawSource = fs.createReadStream(filePath);
|
|
1028
|
+
let prepared;
|
|
1029
|
+
try {
|
|
1030
|
+
prepared = await withTimeout(this.prepareFileSource(rawSource, encoding, extname(filePath)), remaining(), "[CSVProcessor] Timed out preparing CSV file source");
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
rawSource.destroy();
|
|
1034
|
+
if (error instanceof TimeoutError) {
|
|
1035
|
+
// Same contract as streamParse's own timeout path (#379): report a
|
|
1036
|
+
// clean partial result instead of throwing, so the whole call never
|
|
1037
|
+
// hangs OR surfaces a timeout as an exception.
|
|
1038
|
+
logger.warn(`[CSVProcessor] Prepare phase exceeded the ${timeoutMs}ms parse budget for ${filePath}; returning an empty partial result`);
|
|
1039
|
+
return { rows: [], timedOut: true };
|
|
1040
|
+
}
|
|
1041
|
+
throw ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
|
|
1042
|
+
stage: "read",
|
|
1043
|
+
count: 0,
|
|
1044
|
+
location: filePath,
|
|
1045
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
1046
|
+
}), { filePath }, error instanceof Error ? error : undefined);
|
|
1047
|
+
}
|
|
1048
|
+
return this.streamParse(prepared.source, rawSource, {
|
|
1049
|
+
maxRows: clampedMaxRows,
|
|
1050
|
+
skipLines: prepared.skipLines,
|
|
1051
|
+
timeoutMs: remaining(),
|
|
1052
|
+
location: filePath,
|
|
1053
|
+
delimiter: prepared.delimiter,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Peek the head of a raw file stream to resolve encoding, metadata-line
|
|
1058
|
+
* skipping, and the delimiter (#361), then return a decoded text stream
|
|
1059
|
+
* that still delivers the whole file from byte 0 (via `unshift`) — a
|
|
1060
|
+
* single disk read (#368/#362).
|
|
1061
|
+
*/
|
|
1062
|
+
static prepareFileSource(rawSource, encodingOverride, extensionHint) {
|
|
757
1063
|
return new Promise((resolve, reject) => {
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
let
|
|
761
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1064
|
+
const PEEK_LIMIT = 64 * 1024;
|
|
1065
|
+
const chunks = [];
|
|
1066
|
+
let total = 0;
|
|
1067
|
+
let settled = false;
|
|
1068
|
+
const cleanup = () => {
|
|
1069
|
+
rawSource.removeListener("data", onData);
|
|
1070
|
+
rawSource.removeListener("end", onEnd);
|
|
1071
|
+
rawSource.removeListener("error", onError);
|
|
766
1072
|
};
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1073
|
+
// #361: resolve the metadata-line skip count and the delimiter
|
|
1074
|
+
// together from the same peeked/decoded text — an explicit `sep=`
|
|
1075
|
+
// line wins outright, otherwise fall back to frequency-based
|
|
1076
|
+
// detection (+ the file extension as a tie-breaker) over the
|
|
1077
|
+
// post-metadata lines.
|
|
1078
|
+
const resolveMeta = (text) => {
|
|
1079
|
+
const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(splitCsvLines(text));
|
|
1080
|
+
const delimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines, extensionHint);
|
|
1081
|
+
return { hasMetadataLine, dataLines, delimiter };
|
|
1082
|
+
};
|
|
1083
|
+
const onData = (chunk) => {
|
|
1084
|
+
chunks.push(chunk);
|
|
1085
|
+
total += chunk.length;
|
|
1086
|
+
const head = Buffer.concat(chunks);
|
|
1087
|
+
// isCompleteBuffer=false: `head` is only a peek, not necessarily the
|
|
1088
|
+
// whole file (#1199) — see the confidence note on decodeBuffer.
|
|
1089
|
+
const { text, encoding } = decodeBuffer(head, encodingOverride, false);
|
|
1090
|
+
if (splitCsvLines(text).length >= 2 || total >= PEEK_LIMIT) {
|
|
1091
|
+
if (settled) {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
settled = true;
|
|
1095
|
+
cleanup();
|
|
1096
|
+
const { hasMetadataLine, delimiter } = resolveMeta(text);
|
|
1097
|
+
// Replay the consumed head bytes, then decode the full raw stream.
|
|
1098
|
+
// NOTE (#1282): the encoding is committed from the peeked head to keep
|
|
1099
|
+
// the parse streaming (which the parse-timeout guard #379 relies on).
|
|
1100
|
+
// A file whose head is pure ASCII but which switches to a legacy
|
|
1101
|
+
// encoding only later would be read as UTF-8; pass `encoding`
|
|
1102
|
+
// explicitly for such files.
|
|
1103
|
+
rawSource.pause();
|
|
1104
|
+
rawSource.unshift(head);
|
|
1105
|
+
const decodeStream = iconv.decodeStream(encoding);
|
|
1106
|
+
rawSource.on("error", (e) => decodeStream.destroy(e));
|
|
1107
|
+
resolve({
|
|
1108
|
+
source: rawSource.pipe(decodeStream),
|
|
1109
|
+
skipLines: hasMetadataLine ? 1 : 0,
|
|
1110
|
+
delimiter,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
const onEnd = () => {
|
|
1115
|
+
if (settled) {
|
|
778
1116
|
return;
|
|
779
1117
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1118
|
+
settled = true;
|
|
1119
|
+
cleanup();
|
|
1120
|
+
// Whole (small) file already buffered — decode it directly. unshift on
|
|
1121
|
+
// an ended stream throws, so replay via an in-memory Readable instead.
|
|
1122
|
+
const head = Buffer.concat(chunks);
|
|
1123
|
+
const { text } = decodeBuffer(head, encodingOverride);
|
|
1124
|
+
const { hasMetadataLine, dataLines, delimiter } = resolveMeta(text);
|
|
1125
|
+
const data = hasMetadataLine ? dataLines.join("\n") : text;
|
|
1126
|
+
resolve({ source: Readable.from([data]), skipLines: 0, delimiter });
|
|
1127
|
+
};
|
|
1128
|
+
const onError = (error) => {
|
|
1129
|
+
if (settled) {
|
|
1130
|
+
return;
|
|
786
1131
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
reject(new Error(`[CSVProcessor] CSV parsing failed after ${count} row(s): ${error.message}`));
|
|
795
|
-
});
|
|
1132
|
+
settled = true;
|
|
1133
|
+
cleanup();
|
|
1134
|
+
reject(error);
|
|
1135
|
+
};
|
|
1136
|
+
rawSource.on("data", onData);
|
|
1137
|
+
rawSource.on("end", onEnd);
|
|
1138
|
+
rawSource.on("error", onError);
|
|
796
1139
|
});
|
|
797
1140
|
}
|
|
798
1141
|
/**
|
|
@@ -801,11 +1144,20 @@ export class CSVProcessor {
|
|
|
801
1144
|
*
|
|
802
1145
|
* @param csvString - CSV data as string
|
|
803
1146
|
* @param maxRows - Maximum rows to parse (default: 1000)
|
|
1147
|
+
* @param timeoutMs - Wall-clock parse cap; returns partial rows on timeout
|
|
804
1148
|
* @returns Array of row objects
|
|
805
1149
|
*/
|
|
806
|
-
static async parseCSVString(csvString, maxRows = 1000) {
|
|
1150
|
+
static async parseCSVString(csvString, maxRows = 1000, timeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS) {
|
|
1151
|
+
const { rows } = await this.parseCSVStringWithMeta(csvString, maxRows, timeoutMs);
|
|
1152
|
+
return rows;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* String-parse variant that also reports whether the parse timed out, used by
|
|
1156
|
+
* `process()` to surface `metadata.parseTimedOut`.
|
|
1157
|
+
*/
|
|
1158
|
+
static async parseCSVStringWithMeta(csvString, maxRows = 1000, timeoutMs = DEFAULT_CSV_STRING_PARSE_TIMEOUT_MS) {
|
|
807
1159
|
if (typeof csvString !== "string" || csvString.trim().length === 0) {
|
|
808
|
-
throw
|
|
1160
|
+
throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVString: csvString must be a non-empty string");
|
|
809
1161
|
}
|
|
810
1162
|
// Strip a leading UTF-8 BOM (common in Excel exports) so it does not glue
|
|
811
1163
|
// onto the first column name and break downstream key lookups.
|
|
@@ -815,49 +1167,148 @@ export class CSVProcessor {
|
|
|
815
1167
|
inputLength: normalized.length,
|
|
816
1168
|
maxRows: clampedMaxRows,
|
|
817
1169
|
});
|
|
818
|
-
// Detect and skip metadata line
|
|
1170
|
+
// Detect and skip a leading metadata line before detecting the
|
|
1171
|
+
// delimiter, so a `sep=` preamble can't skew detection (#361). No
|
|
1172
|
+
// caller currently needs to override the delimiter here (#1192's own
|
|
1173
|
+
// tests rely on self-detection), so it's always auto-detected.
|
|
819
1174
|
const lines = splitCsvLines(normalized);
|
|
820
|
-
const hasMetadataLine =
|
|
821
|
-
const
|
|
822
|
-
? lines.slice(1).join("\n")
|
|
823
|
-
: lines.join("\n");
|
|
1175
|
+
const { dataLines, hasMetadataLine, explicitDelimiter } = stripMetadataLine(lines);
|
|
1176
|
+
const effectiveDelimiter = explicitDelimiter ?? detectDelimiterUtil(dataLines);
|
|
824
1177
|
if (hasMetadataLine) {
|
|
825
1178
|
logger.debug("[CSVProcessor] Detected metadata line in string, skipping");
|
|
826
1179
|
}
|
|
1180
|
+
const csvData = dataLines.join("\n");
|
|
1181
|
+
if (csvData.trim().length === 0) {
|
|
1182
|
+
throw ErrorFactory.csvInvalidInput("CSVProcessor.parseCSVString: csvString must be a non-empty string");
|
|
1183
|
+
}
|
|
1184
|
+
return this.streamParse(Readable.from([csvData]), undefined, {
|
|
1185
|
+
maxRows: clampedMaxRows,
|
|
1186
|
+
skipLines: 0,
|
|
1187
|
+
timeoutMs,
|
|
1188
|
+
delimiter: effectiveDelimiter,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Shared streaming consumer for both parse methods. Guarantees:
|
|
1193
|
+
* - source, rawSource, and parser are all destroyed on ANY settle path
|
|
1194
|
+
* (#371) — `rawSource` matters for the file-parse path, where `source`
|
|
1195
|
+
* is a decode Transform piped FROM `rawSource`; `.pipe()` does not
|
|
1196
|
+
* propagate `.destroy()` upstream, so without this the underlying
|
|
1197
|
+
* `fs.createReadStream` fd would leak on abort/timeout/error,
|
|
1198
|
+
* - a wall-clock timeout returns partial rows instead of hanging (#379),
|
|
1199
|
+
* - every row is validated at the boundary (#384),
|
|
1200
|
+
* - reject messages carry columns/last-row-shape/format context (#375),
|
|
1201
|
+
* - the delimiter (#361, comma/tab/semicolon/pipe) detected by the caller
|
|
1202
|
+
* is honored, instead of csv-parser's hard-coded comma default.
|
|
1203
|
+
*
|
|
1204
|
+
* @param rawSource - The original file stream `source` was piped from, when
|
|
1205
|
+
* applicable (file-parse path only; `undefined` for string parsing).
|
|
1206
|
+
*/
|
|
1207
|
+
static streamParse(source, rawSource, opts) {
|
|
827
1208
|
return new Promise((resolve, reject) => {
|
|
828
1209
|
const rows = [];
|
|
829
1210
|
let count = 0;
|
|
830
|
-
|
|
831
|
-
|
|
1211
|
+
let settled = false;
|
|
1212
|
+
let capturedHeaders;
|
|
1213
|
+
let lastRowColumnCount;
|
|
1214
|
+
const startTime = Date.now();
|
|
1215
|
+
// maxRowBytes bounds a single-row allocation (#371) — without it a
|
|
1216
|
+
// pathological unbroken row grows memory unbounded and never errors.
|
|
1217
|
+
// skipLines drops any pre-header metadata line (e.g. Excel's `sep=,`)
|
|
1218
|
+
// BEFORE csv-parser reads the header, so the real header is used.
|
|
1219
|
+
// separator (#361) defaults to comma when the caller has no better
|
|
1220
|
+
// signal, matching csv-parser's own default.
|
|
1221
|
+
const parser = csvParser({
|
|
1222
|
+
separator: opts.delimiter ?? ",",
|
|
1223
|
+
maxRowBytes: CSV_MAX_ROW_BYTES,
|
|
1224
|
+
...(opts.skipLines > 0 ? { skipLines: opts.skipLines } : {}),
|
|
1225
|
+
});
|
|
832
1226
|
const abort = () => {
|
|
833
1227
|
source.destroy();
|
|
1228
|
+
rawSource?.destroy();
|
|
834
1229
|
parser.destroy();
|
|
835
1230
|
};
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1231
|
+
const finish = (fn) => {
|
|
1232
|
+
if (settled) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
settled = true;
|
|
1236
|
+
clearTimeout(timer);
|
|
1237
|
+
fn();
|
|
1238
|
+
};
|
|
1239
|
+
const timer = setTimeout(() => {
|
|
1240
|
+
finish(() => {
|
|
1241
|
+
logger.warn(`[CSVProcessor] Parse timed out after ${Date.now() - startTime}ms with ${rows.length} partial row(s)`);
|
|
1242
|
+
abort();
|
|
1243
|
+
resolve({ rows, timedOut: true });
|
|
1244
|
+
});
|
|
1245
|
+
}, opts.timeoutMs);
|
|
1246
|
+
if (typeof timer.unref === "function") {
|
|
1247
|
+
timer.unref();
|
|
1248
|
+
}
|
|
1249
|
+
parser.on("headers", (h) => {
|
|
1250
|
+
capturedHeaders = h;
|
|
1251
|
+
});
|
|
1252
|
+
// .pipe() does not forward source errors (disk/permission failures) to
|
|
1253
|
+
// the destination, so listen on the source directly or it throws.
|
|
839
1254
|
source.on("error", (error) => {
|
|
840
|
-
|
|
841
|
-
|
|
1255
|
+
finish(() => {
|
|
1256
|
+
abort();
|
|
1257
|
+
reject(ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
|
|
1258
|
+
stage: "read",
|
|
1259
|
+
count,
|
|
1260
|
+
headerCount: capturedHeaders?.length,
|
|
1261
|
+
lastRowColumnCount,
|
|
1262
|
+
location: opts.location,
|
|
1263
|
+
cause: error.message,
|
|
1264
|
+
}), { location: opts.location, rowCount: count }, error));
|
|
1265
|
+
});
|
|
842
1266
|
});
|
|
843
1267
|
source
|
|
844
1268
|
.pipe(parser)
|
|
845
1269
|
.on("data", (row) => {
|
|
1270
|
+
try {
|
|
1271
|
+
assertValidCsvRow(row, count + 1);
|
|
1272
|
+
}
|
|
1273
|
+
catch (err) {
|
|
1274
|
+
finish(() => {
|
|
1275
|
+
abort();
|
|
1276
|
+
reject(err);
|
|
1277
|
+
});
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
lastRowColumnCount = Object.keys(row).length;
|
|
846
1281
|
rows.push(row);
|
|
847
1282
|
count++;
|
|
848
|
-
if (count >=
|
|
849
|
-
logger.debug(`[CSVProcessor] Reached row limit ${
|
|
850
|
-
|
|
851
|
-
|
|
1283
|
+
if (count >= opts.maxRows) {
|
|
1284
|
+
logger.debug(`[CSVProcessor] Reached row limit ${opts.maxRows}, stopping parse`);
|
|
1285
|
+
finish(() => {
|
|
1286
|
+
abort();
|
|
1287
|
+
resolve({ rows, timedOut: false });
|
|
1288
|
+
});
|
|
852
1289
|
}
|
|
853
1290
|
})
|
|
854
1291
|
.on("end", () => {
|
|
855
|
-
|
|
856
|
-
|
|
1292
|
+
finish(() => {
|
|
1293
|
+
logger.debug(`[CSVProcessor] Parsing complete: ${rows.length} rows parsed`);
|
|
1294
|
+
resolve({ rows, timedOut: false });
|
|
1295
|
+
});
|
|
857
1296
|
})
|
|
858
1297
|
.on("error", (error) => {
|
|
859
|
-
|
|
860
|
-
|
|
1298
|
+
// #371: destroy the source too — .pipe() does NOT auto-destroy the
|
|
1299
|
+
// upstream when the parser Transform errors, leaking the fd/stream.
|
|
1300
|
+
finish(() => {
|
|
1301
|
+
abort();
|
|
1302
|
+
logger.error("[CSVProcessor] Parsing failed:", error);
|
|
1303
|
+
reject(ErrorFactory.csvParseFailed(buildCsvParseErrorMessage({
|
|
1304
|
+
stage: "parse",
|
|
1305
|
+
count,
|
|
1306
|
+
headerCount: capturedHeaders?.length,
|
|
1307
|
+
lastRowColumnCount,
|
|
1308
|
+
location: opts.location,
|
|
1309
|
+
cause: error.message,
|
|
1310
|
+
}), { location: opts.location, rowCount: count }, error));
|
|
1311
|
+
});
|
|
861
1312
|
});
|
|
862
1313
|
});
|
|
863
1314
|
}
|
|
@@ -896,9 +1347,7 @@ export class CSVProcessor {
|
|
|
896
1347
|
rows.forEach((row) => {
|
|
897
1348
|
markdown +=
|
|
898
1349
|
"| " +
|
|
899
|
-
headers
|
|
900
|
-
.map((h) => escapePipe(String(row[h] || "")))
|
|
901
|
-
.join(" | ") +
|
|
1350
|
+
headers.map((h) => escapePipe(String(row[h] || ""))).join(" | ") +
|
|
902
1351
|
" |\n";
|
|
903
1352
|
});
|
|
904
1353
|
return markdown;
|