Skip to content

Commit e642274

Browse files
committed
fix(tables): observe EOF at the exact sniff-window boundary
The stream sniffer's read loop stopped as soon as the buffered size reached the sniff window, so a file whose size is exactly the window never triggered the extra read that observes end-of-stream — it was judged a truncated prefix and dropped its final newline-less row, disagreeing with the buffered callers (which mark that size complete). Read while size <= window so the boundary case sees EOF and `exhausted` (hence `complete`) is set correctly. Regression test added.
1 parent 4312df9 commit e642274

2 files changed

Lines changed: 24 additions & 4 deletions

File tree

apps/sim/lib/table/csv-delimiter-stream.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ export async function sniffCsvDelimiterFromStream(
3535
let size = 0
3636
let exhausted = false
3737

38-
while (size < CSV_DELIMITER_SNIFF_BYTES) {
38+
// Read until the buffered size *exceeds* the window (not just reaches it) or the stream ends.
39+
// The `<=` is deliberate: a file whose size is exactly the window must still trigger one more
40+
// read so EOF is observed and `exhausted` becomes true — otherwise it would be misjudged as a
41+
// truncated prefix and disagree with the buffered callers (which pass complete for that size).
42+
while (size <= CSV_DELIMITER_SNIFF_BYTES) {
3943
const next = await reader.next()
4044
if (next.done) {
4145
exhausted = true
@@ -49,9 +53,9 @@ export async function sniffCsvDelimiterFromStream(
4953
// Copy at most the sniff window for detection — `Buffer.concat`'s length arg truncates,
5054
// so an oversized final chunk can't inflate this allocation past CSV_DELIMITER_SNIFF_BYTES.
5155
const sample = Buffer.concat(chunks, Math.min(size, CSV_DELIMITER_SNIFF_BYTES))
52-
// `exhausted` (the loop stopped on end-of-stream, never on the size cap) means the whole file
53-
// fit in the window, so its last row must count even without a trailing newline. Otherwise the
54-
// sample is a truncated prefix whose final line may be partial and should be dropped.
56+
// `exhausted` (the loop stopped on end-of-stream, never on exceeding the window) means the whole
57+
// file fit in the window, so its last row must count even without a trailing newline. Otherwise
58+
// the sample is a truncated prefix whose final line may be partial and should be dropped.
5559
const delimiter = await detectCsvDelimiter(sample, fallback, { complete: exhausted })
5660

5761
const stream = Readable.from(

apps/sim/lib/table/import.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
66
import { sniffCsvDelimiterFromStream } from '@/lib/table/csv-delimiter-stream'
77
import {
88
buildAutoMapping,
9+
CSV_DELIMITER_SNIFF_BYTES,
910
CsvImportValidationError,
1011
coerceRowsForTable,
1112
coerceValue,
@@ -465,6 +466,21 @@ describe('import', () => {
465466
expect((await collect(stream)).equals(full)).toBe(true)
466467
})
467468

469+
it('treats a file exactly the sniff-window size as complete (observes EOF at the boundary)', async () => {
470+
// Header widens under comma; a single giant data row pads the file to exactly the window
471+
// with no trailing newline. If the loop stopped at the boundary without seeing EOF, the row
472+
// would be dropped and comma would win — so this passing proves the boundary EOF is observed.
473+
const header = 'a,b;c,d;e,f\n'
474+
const suffix = ';3'
475+
const pad = 'x'.repeat(
476+
CSV_DELIMITER_SNIFF_BYTES - header.length - '1;'.length - suffix.length
477+
)
478+
const full = Buffer.from(`${header}1;${pad}${suffix}`)
479+
expect(full.length).toBe(CSV_DELIMITER_SNIFF_BYTES)
480+
const { delimiter } = await sniffCsvDelimiterFromStream(Readable.from([full]))
481+
expect(delimiter).toBe(';')
482+
})
483+
468484
it('counts the final row of a fully-buffered file with no trailing newline', async () => {
469485
// The whole file fits the sniff window (exhausted), so complete:true flows through and the
470486
// last row is scored — semicolon must win despite comma widening the header row.

0 commit comments

Comments
 (0)