@@ -40,6 +40,13 @@ const logger = createLogger('ImportCsvDialog')
4040
4141const MAX_SAMPLE_ROWS = 5
4242const 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
107129export 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