Skip to content

Commit 3811a5d

Browse files
committed
fix(tables): keep the final row when sniffing a complete CSV
detectCsvDelimiter always trimmed to the last newline, so a complete file with no trailing newline lost its final data row from scoring — leaving the header alone and letting a header-widening separator beat the real one. It now takes a `complete` flag: the trim runs only for truncated prefixes, where a mid-record cut must be dropped. The stream sniffer passes it from `exhausted`, and the buffered callers (client preview, parseFileRows) pass it when the sample covers the whole file.
1 parent 33286cf commit 3811a5d

4 files changed

Lines changed: 41 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,12 @@ async function parseCsvPreview(file: File, fallbackDelimiter: CsvDelimiter) {
127127
const lastNewline = bytes.lastIndexOf(0x0a)
128128
if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1)
129129
}
130+
// The sniff sample is the whole file only when nothing was sliced off and it fits the window;
131+
// otherwise it's a truncated prefix whose last line may be partial.
130132
const delimiter = await detectCsvDelimiter(
131133
bytes.subarray(0, CSV_DELIMITER_SNIFF_BYTES),
132-
fallbackDelimiter
134+
fallbackDelimiter,
135+
{ complete: !sliced && bytes.length <= CSV_DELIMITER_SNIFF_BYTES }
133136
)
134137
return parseCsvBuffer(bytes, delimiter)
135138
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,12 @@ export async function sniffCsvDelimiterFromStream(
4848

4949
// Copy at most the sniff window for detection — `Buffer.concat`'s length arg truncates,
5050
// so an oversized final chunk can't inflate this allocation past CSV_DELIMITER_SNIFF_BYTES.
51-
const sample = Buffer.concat(chunks, Math.min(size, CSV_DELIMITER_SNIFF_BYTES))
52-
const delimiter = await detectCsvDelimiter(sample, fallback)
51+
// `exhausted` means the whole file fit in the window, so its last row must count even
52+
// without a trailing newline; otherwise the sample is a prefix and its tail may be partial.
53+
const capped = Math.min(size, CSV_DELIMITER_SNIFF_BYTES)
54+
const sample = Buffer.concat(chunks, capped)
55+
const complete = exhausted && size <= CSV_DELIMITER_SNIFF_BYTES
56+
const delimiter = await detectCsvDelimiter(sample, fallback, { complete })
5357

5458
const stream = Readable.from(
5559
(async function* replay() {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,15 @@ describe('import', () => {
426426
expect(await detectCsvDelimiter('a;b;c\n1;2;3\n4;5;')).toBe(';')
427427
})
428428

429+
it('keeps the final row of a complete file that has no trailing newline', async () => {
430+
// Without complete:true the trim would drop the only distinguishing data row, leaving the
431+
// header alone and letting comma (which merely widens the header) win over semicolon.
432+
const csv = 'a,b;c,d;e,f\n1;2;3'
433+
expect(await detectCsvDelimiter(csv, ',', { complete: true })).toBe(';')
434+
// A truncated prefix (complete:false, the default) still drops the partial tail.
435+
expect(await detectCsvDelimiter(csv)).toBe(',')
436+
})
437+
429438
it('returns the fallback for empty input', async () => {
430439
expect(await detectCsvDelimiter('', ';')).toBe(';')
431440
})
@@ -456,6 +465,15 @@ describe('import', () => {
456465
expect((await collect(stream)).equals(full)).toBe(true)
457466
})
458467

468+
it('counts the final row of a fully-buffered file with no trailing newline', async () => {
469+
// The whole file fits the sniff window (exhausted), so complete:true flows through and the
470+
// last row is scored — semicolon must win despite comma widening the header row.
471+
const full = Buffer.from('a,b;c,d;e,f\n1;2;3')
472+
const { delimiter, stream } = await sniffCsvDelimiterFromStream(Readable.from([full]))
473+
expect(delimiter).toBe(';')
474+
expect((await collect(stream)).equals(full)).toBe(true)
475+
})
476+
459477
it('detects and replays exactly when a single chunk far exceeds the sniff window', async () => {
460478
// One >1 MiB chunk arrives before the size check — detection must still cap its copy
461479
// and the replay must emit the original bytes unchanged.

apps/sim/lib/table/import.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,20 +127,24 @@ export function decodeCsvText(input: Buffer | Uint8Array | string): string {
127127
* produce a valid table.
128128
*
129129
* All callers funnel through here, so the sample is prepared identically for
130-
* every import path: a possibly-partial trailing line (from slicing a fixed byte
131-
* window out of a larger file) is dropped before parsing so a mid-record cut
132-
* can't skew the counts.
130+
* every import path: when the sample is a prefix sliced out of a larger file, a
131+
* possibly-partial trailing line is dropped before parsing so a mid-record cut
132+
* can't skew the counts. Pass `complete: true` when the sample is the entire file
133+
* (nothing was sliced off) so a final row with no trailing newline still counts —
134+
* dropping it would silently discard the only distinguishing data row of a tiny
135+
* file and let a header-widening separator win.
133136
*/
134137
export async function detectCsvDelimiter(
135138
input: Buffer | Uint8Array | string,
136-
fallback: CsvDelimiter = ','
139+
fallback: CsvDelimiter = ',',
140+
{ complete = false }: { complete?: boolean } = {}
137141
): Promise<CsvDelimiter> {
138142
const { parse } = await import('csv-parse/sync')
139143
const decoded = decodeCsvText(input)
140-
// Drop a partial final line when the sample is a prefix of a larger file; keep the
141-
// whole thing when it's a single line (no newline) so a tiny full file still parses.
144+
// Drop a partial final line only when the sample is a truncated prefix; keep every row when
145+
// the sample is the whole file (or a single line) so a trailing-newline-less last row counts.
142146
const lastNewline = decoded.lastIndexOf('\n')
143-
const text = lastNewline > 0 ? decoded.slice(0, lastNewline + 1) : decoded
147+
const text = !complete && lastNewline > 0 ? decoded.slice(0, lastNewline + 1) : decoded
144148
if (text.trim() === '') return fallback
145149

146150
let best: { delimiter: CsvDelimiter; fields: number; score: number } | null = null
@@ -648,7 +652,8 @@ export async function parseFileRows(
648652
if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') {
649653
const delimiter = await detectCsvDelimiter(
650654
buffer.subarray(0, CSV_DELIMITER_SNIFF_BYTES),
651-
ext === 'tsv' ? '\t' : ','
655+
ext === 'tsv' ? '\t' : ',',
656+
{ complete: buffer.length <= CSV_DELIMITER_SNIFF_BYTES }
652657
)
653658
return parseCsvBuffer(buffer, delimiter)
654659
}

0 commit comments

Comments
 (0)