@@ -23,14 +23,26 @@ import {
2323 TableRow ,
2424 toast ,
2525} from '@/components/emcn'
26+ import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
2627import { buildAutoMapping , parseCsvBuffer } from '@/lib/table/import'
2728import type { TableDefinition } from '@/lib/table/types'
28- import { type CsvImportMode , useImportCsvIntoTable } from '@/hooks/queries/tables'
29+ import {
30+ type CsvImportMode ,
31+ cancelTableImport ,
32+ useImportCsvIntoTable ,
33+ useImportCsvIntoTableAsync ,
34+ } from '@/hooks/queries/tables'
35+ import { useImportTrayStore } from '@/stores/table/import-tray/store'
2936
3037const logger = createLogger ( 'ImportCsvDialog' )
3138
3239const MAX_SAMPLE_ROWS = 5
3340const MAX_EXAMPLES_IN_ERROR = 3
41+ /**
42+ * Bytes read for the preview/mapping. We never parse the whole file client-side — the importer
43+ * streams it server-side and the DB row-count trigger enforces the row limit.
44+ */
45+ const CSV_PREVIEW_BYTES = 512 * 1024
3446/**
3547 * Sentinel value for the "Do not import" option in the mapping combobox. The
3648 * whitespace is intentional: valid column names must match `NAME_PATTERN`
@@ -92,7 +104,18 @@ interface ParsedCsv {
92104 file : File
93105 headers : string [ ]
94106 sampleRows : Record < string , unknown > [ ]
95- totalRows : number
107+ }
108+
109+ /** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */
110+ async function parseCsvPreview ( file : File , delimiter : ',' | '\t' ) {
111+ const sliced = file . size > CSV_PREVIEW_BYTES
112+ const blob = sliced ? file . slice ( 0 , CSV_PREVIEW_BYTES ) : file
113+ let bytes = new Uint8Array ( await blob . arrayBuffer ( ) )
114+ if ( sliced ) {
115+ const lastNewline = bytes . lastIndexOf ( 0x0a )
116+ if ( lastNewline > 0 ) bytes = bytes . subarray ( 0 , lastNewline + 1 )
117+ }
118+ return parseCsvBuffer ( bytes , delimiter )
96119}
97120
98121export function ImportCsvDialog ( {
@@ -110,6 +133,7 @@ export function ImportCsvDialog({
110133 const [ createHeaders , setCreateHeaders ] = useState < Set < string > > ( new Set ( ) )
111134 const [ mode , setMode ] = useState < CsvImportMode > ( 'append' )
112135 const importMutation = useImportCsvIntoTable ( )
136+ const importAsyncMutation = useImportCsvIntoTableAsync ( )
113137
114138 function resetState ( ) {
115139 setParsed ( null )
@@ -155,15 +179,13 @@ export function ImportCsvDialog({
155179 setParsing ( true )
156180 setParseError ( null )
157181 try {
158- const arrayBuffer = await file . arrayBuffer ( )
159- const delimiter = ext === 'tsv' ? '\t' : ','
160- const { headers, rows } = await parseCsvBuffer ( new Uint8Array ( arrayBuffer ) , delimiter )
182+ const delimiter : ',' | '\t' = ext === 'tsv' ? '\t' : ','
183+ const { headers, rows } = await parseCsvPreview ( file , delimiter )
161184 const autoMapping = buildAutoMapping ( headers , table . schema )
162185 setParsed ( {
163186 file,
164187 headers,
165188 sampleRows : rows . slice ( 0 , MAX_SAMPLE_ROWS ) ,
166- totalRows : rows . length ,
167189 } )
168190 setMapping ( autoMapping )
169191 } catch ( err ) {
@@ -256,36 +278,70 @@ export function ImportCsvDialog({
256278 }
257279 } , [ mapping , parsed ?. headers , table . schema . columns , createHeaders ] )
258280
259- const appendCapacityDeficit =
260- parsed && mode === 'append' && table . rowCount + parsed . totalRows > table . maxRows
261- ? table . rowCount + parsed . totalRows - table . maxRows
262- : 0
263-
264- const replaceCapacityDeficit =
265- parsed && mode === 'replace' && parsed . totalRows > table . maxRows
266- ? parsed . totalRows - table . maxRows
267- : 0
268-
269281 const canSubmit =
270282 parsed !== null &&
271283 ! importMutation . isPending &&
284+ ! importAsyncMutation . isPending &&
272285 missingRequired . length === 0 &&
273286 duplicateTargets . length === 0 &&
274- mappedCount + createCount > 0 &&
275- appendCapacityDeficit === 0 &&
276- replaceCapacityDeficit === 0
287+ mappedCount + createCount > 0
277288
278289 async function handleSubmit ( ) {
279290 if ( ! parsed || ! canSubmit ) return
280291 setSubmitError ( null )
292+ const createColumns = createHeaders . size > 0 ? [ ...createHeaders ] : undefined
293+
294+ // Large files can't be POSTed through the server (request-body cap) — upload them
295+ // straight to storage and import in the background instead. Seed the header tray and
296+ // close the dialog immediately so the indicator is visible during the upload, then run
297+ // the upload + kickoff in the background (don't block the dialog on it).
298+ if ( parsed . file . size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES ) {
299+ useImportTrayStore . getState ( ) . startUpload ( {
300+ uploadId : table . id ,
301+ workspaceId,
302+ title : parsed . file . name ,
303+ } )
304+ onOpenChange ( false )
305+ toast . success ( `Importing "${ parsed . file . name } " into "${ table . name } " in the background` )
306+ importAsyncMutation . mutate (
307+ {
308+ workspaceId,
309+ tableId : table . id ,
310+ file : parsed . file ,
311+ mode,
312+ mapping,
313+ createColumns,
314+ onProgress : ( percent ) => {
315+ useImportTrayStore . getState ( ) . setUploadPercent ( table . id , percent )
316+ } ,
317+ } ,
318+ {
319+ onSuccess : ( data ) => {
320+ useImportTrayStore . getState ( ) . endUpload ( table . id )
321+ // The server row drives the tray once the list refetches. If canceled mid-upload, flag
322+ // the id so it's not shown and cancel the worker server-side.
323+ if ( useImportTrayStore . getState ( ) . consumeCanceled ( table . id ) && data ?. importId ) {
324+ useImportTrayStore . getState ( ) . cancel ( table . id )
325+ void cancelTableImport ( workspaceId , table . id , data . importId ) . catch ( ( ) => { } )
326+ }
327+ } ,
328+ onError : ( ) => {
329+ // The hook's onError surfaces the toast; just clear the tray indicator here.
330+ useImportTrayStore . getState ( ) . endUpload ( table . id )
331+ } ,
332+ }
333+ )
334+ return
335+ }
336+
281337 try {
282338 const result = await importMutation . mutateAsync ( {
283339 workspaceId,
284340 tableId : table . id ,
285341 file : parsed . file ,
286342 mode,
287343 mapping,
288- createColumns : createHeaders . size > 0 ? [ ... createHeaders ] : undefined ,
344+ createColumns,
289345 } )
290346 const data = result . data
291347 if ( mode === 'append' ) {
@@ -307,11 +363,7 @@ export function ImportCsvDialog({
307363 }
308364 }
309365
310- const hasWarning =
311- missingRequired . length > 0 ||
312- duplicateTargets . length > 0 ||
313- appendCapacityDeficit > 0 ||
314- replaceCapacityDeficit > 0
366+ const hasWarning = missingRequired . length > 0 || duplicateTargets . length > 0
315367
316368 return (
317369 < ChipModal
@@ -344,7 +396,7 @@ export function ImportCsvDialog({
344396 { parsed . file . name }
345397 </ span >
346398 < span className = 'text-[var(--text-tertiary)] text-xs' >
347- { parsed . totalRows . toLocaleString ( ) } rows · { parsed . headers . length } columns
399+ { parsed . headers . length } columns
348400 </ span >
349401 </ div >
350402 < Button variant = 'ghost' size = 'sm' onClick = { resetState } >
@@ -442,20 +494,6 @@ export function ImportCsvDialog({
442494 Multiple CSV columns target: { duplicateTargets . join ( ', ' ) } (pick one)
443495 </ p >
444496 ) }
445- { appendCapacityDeficit > 0 && (
446- < p className = 'text-[var(--text-error)] text-caption leading-tight' >
447- Append would exceed the row limit ({ table . maxRows . toLocaleString ( ) } ) by{ ' ' }
448- { appendCapacityDeficit . toLocaleString ( ) } row(s). Remove rows or switch to
449- Replace.
450- </ p >
451- ) }
452- { replaceCapacityDeficit > 0 && (
453- < p className = 'text-[var(--text-error)] text-caption leading-tight' >
454- CSV has { parsed . totalRows . toLocaleString ( ) } rows, which exceeds the table limit
455- of { table . maxRows . toLocaleString ( ) } by { replaceCapacityDeficit . toLocaleString ( ) }
456- .
457- </ p >
458- ) }
459497 </ div >
460498 ) }
461499
0 commit comments