Skip to content

Commit a69d15b

Browse files
improvement(tables): preview CSV import from a slice, drop client row-count warning
The import dialog parsed the entire file in the browser to show an exact row count and a row-limit warning. That holds the whole file in memory, blocks the main thread, and hits V8's ~512MB string ceiling — so the dialog capped the effective import size well below what the streaming importer handles. Parse only the first 512KB (headers + sample for the mapping); drop the exact count and the "would exceed the row limit by N" gate. The DB row-count trigger already enforces max_rows server-side, so an over-limit import fails fast during the run with a clear message instead of being blocked by an expensive parse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ebbba86 commit a69d15b

1 file changed

Lines changed: 31 additions & 38 deletions

File tree

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

Lines changed: 31 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ const logger = createLogger('ImportCsvDialog')
4040

4141
const MAX_SAMPLE_ROWS = 5
4242
const MAX_EXAMPLES_IN_ERROR = 3
43+
/**
44+
* How many bytes of the file we read to build the preview + column mapping. We never parse the
45+
* whole file in the browser: a large CSV would freeze the tab and a file past ~512 MB blows V8's
46+
* max string length outright. The streaming importer reads the full file server-side and the DB
47+
* row-count trigger enforces the table limit, so the client only needs the header + a few rows.
48+
*/
49+
const CSV_PREVIEW_BYTES = 512 * 1024
4350
/**
4451
* Sentinel value for the "Do not import" option in the mapping combobox. The
4552
* whitespace is intentional: valid column names must match `NAME_PATTERN`
@@ -101,7 +108,22 @@ interface ParsedCsv {
101108
file: File
102109
headers: string[]
103110
sampleRows: Record<string, unknown>[]
104-
totalRows: number
111+
}
112+
113+
/**
114+
* Parses only the head of a CSV/TSV — enough for the column mapping and sample values, never the
115+
* whole file (see {@link CSV_PREVIEW_BYTES}). When the file is larger than the preview window we
116+
* drop the trailing partial line so the parser never sees a truncated final record.
117+
*/
118+
async function parseCsvPreview(file: File, delimiter: ',' | '\t') {
119+
const sliced = file.size > CSV_PREVIEW_BYTES
120+
const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file
121+
let bytes = new Uint8Array(await blob.arrayBuffer())
122+
if (sliced) {
123+
const lastNewline = bytes.lastIndexOf(0x0a)
124+
if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1)
125+
}
126+
return parseCsvBuffer(bytes, delimiter)
105127
}
106128

107129
export function ImportCsvDialog({
@@ -169,15 +191,13 @@ export function ImportCsvDialog({
169191
setParsing(true)
170192
setParseError(null)
171193
try {
172-
const arrayBuffer = await file.arrayBuffer()
173-
const delimiter = ext === 'tsv' ? '\t' : ','
174-
const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter)
194+
const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ','
195+
const { headers, rows } = await parseCsvPreview(file, delimiter)
175196
const autoMapping = buildAutoMapping(headers, table.schema)
176197
setParsed({
177198
file,
178199
headers,
179200
sampleRows: rows.slice(0, MAX_SAMPLE_ROWS),
180-
totalRows: rows.length,
181201
})
182202
setMapping(autoMapping)
183203
} catch (err) {
@@ -291,25 +311,13 @@ export function ImportCsvDialog({
291311
}
292312
}, [mapping, parsed?.headers, table.schema.columns, createHeaders])
293313

294-
const appendCapacityDeficit =
295-
parsed && mode === 'append' && table.rowCount + parsed.totalRows > table.maxRows
296-
? table.rowCount + parsed.totalRows - table.maxRows
297-
: 0
298-
299-
const replaceCapacityDeficit =
300-
parsed && mode === 'replace' && parsed.totalRows > table.maxRows
301-
? parsed.totalRows - table.maxRows
302-
: 0
303-
304314
const canSubmit =
305315
parsed !== null &&
306316
!importMutation.isPending &&
307317
!importAsyncMutation.isPending &&
308318
missingRequired.length === 0 &&
309319
duplicateTargets.length === 0 &&
310-
mappedCount + createCount > 0 &&
311-
appendCapacityDeficit === 0 &&
312-
replaceCapacityDeficit === 0
320+
mappedCount + createCount > 0
313321

314322
async function handleSubmit() {
315323
if (!parsed || !canSubmit) return
@@ -360,6 +368,9 @@ export function ImportCsvDialog({
360368
// Canceled mid-upload — the worker just started; cancel it instead of re-seeding.
361369
if (useImportTrayStore.getState().consumeCanceled(table.id)) {
362370
if (data?.importId) {
371+
// Re-flag so hydration won't re-seed the still-`importing` server row while the
372+
// server cancel is in flight.
373+
useImportTrayStore.getState().cancel(table.id)
363374
void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {})
364375
}
365376
return
@@ -412,11 +423,7 @@ export function ImportCsvDialog({
412423
}
413424
}
414425

415-
const hasWarning =
416-
missingRequired.length > 0 ||
417-
duplicateTargets.length > 0 ||
418-
appendCapacityDeficit > 0 ||
419-
replaceCapacityDeficit > 0
426+
const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0
420427

421428
return (
422429
<Modal open={open} onOpenChange={handleOpenChange}>
@@ -475,7 +482,7 @@ export function ImportCsvDialog({
475482
{parsed.file.name}
476483
</span>
477484
<span className='text-[var(--text-tertiary)] text-xs'>
478-
{parsed.totalRows.toLocaleString()} rows · {parsed.headers.length} columns
485+
{parsed.headers.length} columns
479486
</span>
480487
</div>
481488
<Button variant='ghost' size='sm' onClick={resetState}>
@@ -574,20 +581,6 @@ export function ImportCsvDialog({
574581
Multiple CSV columns target: {duplicateTargets.join(', ')} (pick one)
575582
</p>
576583
)}
577-
{appendCapacityDeficit > 0 && (
578-
<p className='text-[var(--text-error)] text-caption leading-tight'>
579-
Append would exceed the row limit ({table.maxRows.toLocaleString()}) by{' '}
580-
{appendCapacityDeficit.toLocaleString()} row(s). Remove rows or switch to
581-
Replace.
582-
</p>
583-
)}
584-
{replaceCapacityDeficit > 0 && (
585-
<p className='text-[var(--text-error)] text-caption leading-tight'>
586-
CSV has {parsed.totalRows.toLocaleString()} rows, which exceeds the table
587-
limit of {table.maxRows.toLocaleString()} by{' '}
588-
{replaceCapacityDeficit.toLocaleString()}.
589-
</p>
590-
)}
591584
</div>
592585
)}
593586

0 commit comments

Comments
 (0)