From fcc28f7dd41770c42f201ffb43f9f72d440eb24d Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Tue, 9 Jun 2026 11:12:20 -0700
Subject: [PATCH 1/3] fix(tables): route large CSV imports to the background
job instead of 413
---
.../import-csv-dialog/import-csv-dialog.tsx | 119 ++++++++++++------
.../workspace/[workspaceId]/tables/tables.tsx | 81 +++++++++---
2 files changed, 143 insertions(+), 57 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
index 8c4fa72c92..3db0e32fc2 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
@@ -23,14 +23,26 @@ import {
TableRow,
toast,
} from '@/components/emcn'
+import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import'
import type { TableDefinition } from '@/lib/table/types'
-import { type CsvImportMode, useImportCsvIntoTable } from '@/hooks/queries/tables'
+import {
+ type CsvImportMode,
+ cancelTableImport,
+ useImportCsvIntoTable,
+ useImportCsvIntoTableAsync,
+} from '@/hooks/queries/tables'
+import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('ImportCsvDialog')
const MAX_SAMPLE_ROWS = 5
const MAX_EXAMPLES_IN_ERROR = 3
+/**
+ * Bytes read for the preview/mapping. We never parse the whole file client-side — the importer
+ * streams it server-side and the DB row-count trigger enforces the row limit.
+ */
+const CSV_PREVIEW_BYTES = 512 * 1024
/**
* Sentinel value for the "Do not import" option in the mapping combobox. The
* whitespace is intentional: valid column names must match `NAME_PATTERN`
@@ -92,7 +104,18 @@ interface ParsedCsv {
file: File
headers: string[]
sampleRows: Record[]
- totalRows: number
+}
+
+/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */
+async function parseCsvPreview(file: File, delimiter: ',' | '\t') {
+ const sliced = file.size > CSV_PREVIEW_BYTES
+ const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file
+ let bytes = new Uint8Array(await blob.arrayBuffer())
+ if (sliced) {
+ const lastNewline = bytes.lastIndexOf(0x0a)
+ if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1)
+ }
+ return parseCsvBuffer(bytes, delimiter)
}
export function ImportCsvDialog({
@@ -110,6 +133,7 @@ export function ImportCsvDialog({
const [createHeaders, setCreateHeaders] = useState>(new Set())
const [mode, setMode] = useState('append')
const importMutation = useImportCsvIntoTable()
+ const importAsyncMutation = useImportCsvIntoTableAsync()
function resetState() {
setParsed(null)
@@ -155,15 +179,13 @@ export function ImportCsvDialog({
setParsing(true)
setParseError(null)
try {
- const arrayBuffer = await file.arrayBuffer()
- const delimiter = ext === 'tsv' ? '\t' : ','
- const { headers, rows } = await parseCsvBuffer(new Uint8Array(arrayBuffer), delimiter)
+ const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ','
+ const { headers, rows } = await parseCsvPreview(file, delimiter)
const autoMapping = buildAutoMapping(headers, table.schema)
setParsed({
file,
headers,
sampleRows: rows.slice(0, MAX_SAMPLE_ROWS),
- totalRows: rows.length,
})
setMapping(autoMapping)
} catch (err) {
@@ -256,28 +278,63 @@ export function ImportCsvDialog({
}
}, [mapping, parsed?.headers, table.schema.columns, createHeaders])
- const appendCapacityDeficit =
- parsed && mode === 'append' && table.rowCount + parsed.totalRows > table.maxRows
- ? table.rowCount + parsed.totalRows - table.maxRows
- : 0
-
- const replaceCapacityDeficit =
- parsed && mode === 'replace' && parsed.totalRows > table.maxRows
- ? parsed.totalRows - table.maxRows
- : 0
-
const canSubmit =
parsed !== null &&
!importMutation.isPending &&
+ !importAsyncMutation.isPending &&
missingRequired.length === 0 &&
duplicateTargets.length === 0 &&
- mappedCount + createCount > 0 &&
- appendCapacityDeficit === 0 &&
- replaceCapacityDeficit === 0
+ mappedCount + createCount > 0
async function handleSubmit() {
if (!parsed || !canSubmit) return
setSubmitError(null)
+ const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined
+
+ // Large files can't be POSTed through the server (request-body cap) — upload them
+ // straight to storage and import in the background instead. Seed the header tray and
+ // close the dialog immediately so the indicator is visible during the upload, then run
+ // the upload + kickoff in the background (don't block the dialog on it).
+ if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) {
+ useImportTrayStore.getState().startUpload({
+ uploadId: table.id,
+ workspaceId,
+ title: parsed.file.name,
+ })
+ onOpenChange(false)
+ toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`)
+ importAsyncMutation.mutate(
+ {
+ workspaceId,
+ tableId: table.id,
+ file: parsed.file,
+ mode,
+ mapping,
+ createColumns,
+ onProgress: (percent) => {
+ useImportTrayStore.getState().setUploadPercent(table.id, percent)
+ },
+ },
+ {
+ onSuccess: (data) => {
+ useImportTrayStore.getState().endUpload(table.id)
+ // The server row drives the tray once the list refetches. If canceled mid-upload, flag
+ // the id so it's not shown and cancel the worker server-side.
+ if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) {
+ useImportTrayStore.getState().cancel(table.id)
+ void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {})
+ }
+ },
+ onError: (err) => {
+ useImportTrayStore.getState().endUpload(table.id)
+ toast.error(getErrorMessage(err, 'Failed to start import'))
+ logger.error('Async CSV import failed to start', err)
+ },
+ }
+ )
+ return
+ }
+
try {
const result = await importMutation.mutateAsync({
workspaceId,
@@ -285,7 +342,7 @@ export function ImportCsvDialog({
file: parsed.file,
mode,
mapping,
- createColumns: createHeaders.size > 0 ? [...createHeaders] : undefined,
+ createColumns,
})
const data = result.data
if (mode === 'append') {
@@ -307,11 +364,7 @@ export function ImportCsvDialog({
}
}
- const hasWarning =
- missingRequired.length > 0 ||
- duplicateTargets.length > 0 ||
- appendCapacityDeficit > 0 ||
- replaceCapacityDeficit > 0
+ const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0
return (
- {parsed.totalRows.toLocaleString()} rows · {parsed.headers.length} columns
+ {parsed.headers.length} columns
)}
- {appendCapacityDeficit > 0 && (
-
- Append would exceed the row limit ({table.maxRows.toLocaleString()}) by{' '}
- {appendCapacityDeficit.toLocaleString()} row(s). Remove rows or switch to
- Replace.
-
- )}
- {replaceCapacityDeficit > 0 && (
-
- CSV has {parsed.totalRows.toLocaleString()} rows, which exceeds the table limit
- of {table.maxRows.toLocaleString()} by {replaceCapacityDeficit.toLocaleString()}
- .
-
- )}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index bbef067020..ebbb4456e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -2,12 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
import { useParams, useRouter } from 'next/navigation'
import type { ComboboxOption } from '@/components/emcn'
import { ChipCombobox, ChipConfirmModal, toast, Upload } from '@/components/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@/components/emcn/icons'
import type { TableDefinition } from '@/lib/table'
-import { generateUniqueTableName } from '@/lib/table/constants'
+import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
import type {
FilterTag,
ResourceColumn,
@@ -24,14 +25,17 @@ import {
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ImportCsvDialog,
+ ImportProgressMenu,
TablesListContextMenu,
} from '@/app/workspace/[workspaceId]/tables/components'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
+ cancelTableImport,
downloadTableExport,
useCreateTable,
useDeleteTable,
+ useImportCsvAsync,
useRenameTable,
useTablesList,
useUploadCsvToTable,
@@ -40,6 +44,7 @@ import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
+import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('Tables')
@@ -76,6 +81,7 @@ export function Tables() {
const renameTable = useRenameTable(workspaceId)
const createTable = useCreateTable(workspaceId)
const uploadCsv = useUploadCsvToTable()
+ const importCsvAsync = useImportCsvAsync()
const tableRename = useInlineRename({
onSave: (tableId, name) => renameTable.mutate({ tableId, name }),
@@ -407,37 +413,77 @@ export function Tables() {
const list = e.target.files
if (!list || list.length === 0 || !workspaceId) return
- try {
- setUploading(true)
+ const csvFiles = Array.from(list).filter((f) => {
+ const ext = f.name.split('.').pop()?.toLowerCase()
+ return ext === 'csv' || ext === 'tsv'
+ })
- const csvFiles = Array.from(list).filter((f) => {
- const ext = f.name.split('.').pop()?.toLowerCase()
- return ext === 'csv' || ext === 'tsv'
- })
+ if (csvFiles.length === 0) {
+ toast.error('No CSV or TSV files selected')
+ if (csvInputRef.current) csvInputRef.current.value = ''
+ return
+ }
+
+ // Large files can't be POSTed through the server (request-body cap) — upload them
+ // straight to storage and import in the background. These are tracked by the import
+ // tray, never the header upload button, so don't touch uploading/uploadProgress here.
+ const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
+ const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
- if (csvFiles.length === 0) {
- toast.error('No CSV or TSV files selected')
- return
+ try {
+ for (const file of asyncFiles) {
+ // Show the indicator immediately under a temporary id (the real table id doesn't
+ // exist until kickoff returns), then let the tray track it. Don't redirect — the
+ // table is still empty/importing, so stay on the list.
+ const pendingId = `pending_${generateId()}`
+ useImportTrayStore
+ .getState()
+ .startUpload({ uploadId: pendingId, workspaceId, title: file.name })
+ toast.success(`Importing "${file.name}" in the background`)
+ try {
+ const result = await importCsvAsync.mutateAsync({
+ workspaceId,
+ file,
+ onProgress: (percent) => {
+ useImportTrayStore.getState().setUploadPercent(pendingId, percent)
+ },
+ })
+ useImportTrayStore.getState().endUpload(pendingId)
+ // The server row drives the tray once the list refetches (mutation invalidates it).
+ // If canceled mid-upload, flag the real id so it's not shown and cancel server-side.
+ if (result?.tableId && useImportTrayStore.getState().consumeCanceled(pendingId)) {
+ useImportTrayStore.getState().cancel(result.tableId)
+ void cancelTableImport(workspaceId, result.tableId, result.importId).catch(() => {})
+ }
+ } catch (err) {
+ useImportTrayStore.getState().endUpload(pendingId)
+ toast.error(`Failed to import ${file.name}`)
+ logger.error('Error uploading CSV:', err)
+ }
}
- setUploadProgress({ completed: 0, total: csvFiles.length })
+ if (syncFiles.length === 0) return
+
+ setUploading(true)
+ setUploadProgress({ completed: 0, total: syncFiles.length })
const failed: string[] = []
- for (let i = 0; i < csvFiles.length; i++) {
+ for (let i = 0; i < syncFiles.length; i++) {
+ const file = syncFiles[i]
try {
- const result = await uploadCsv.mutateAsync({ workspaceId, file: csvFiles[i] })
+ const result = await uploadCsv.mutateAsync({ workspaceId, file })
- if (csvFiles.length === 1) {
+ if (syncFiles.length === 1 && asyncFiles.length === 0) {
const tableId = result?.data?.table?.id
if (tableId) {
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
}
}
} catch (err) {
- failed.push(csvFiles[i].name)
+ failed.push(file.name)
logger.error('Error uploading CSV:', err)
} finally {
- setUploadProgress({ completed: i + 1, total: csvFiles.length })
+ setUploadProgress({ completed: i + 1, total: syncFiles.length })
}
}
@@ -459,7 +505,7 @@ export function Tables() {
}
}
},
- [workspaceId, router, uploadCsv]
+ [workspaceId, router, uploadCsv, importCsvAsync]
)
const handleListUploadCsv = useCallback(() => {
@@ -508,6 +554,7 @@ export function Tables() {
sort={sortConfig}
filter={filterContent}
filterTags={filterTags}
+ leadingActions={}
headerActions={[
{
label: uploadButtonLabel,
From 0c702a20da2172fe325f3dc04fcf99e73c253fdf Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Tue, 9 Jun 2026 11:18:13 -0700
Subject: [PATCH 2/3] fix(tables): drop duplicate error toast on async import
failure
---
.../components/import-csv-dialog/import-csv-dialog.tsx | 5 ++---
apps/sim/app/workspace/[workspaceId]/tables/tables.tsx | 5 ++---
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
index 3db0e32fc2..987f7db6e9 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx
@@ -325,10 +325,9 @@ export function ImportCsvDialog({
void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {})
}
},
- onError: (err) => {
+ onError: () => {
+ // The hook's onError surfaces the toast; just clear the tray indicator here.
useImportTrayStore.getState().endUpload(table.id)
- toast.error(getErrorMessage(err, 'Failed to start import'))
- logger.error('Async CSV import failed to start', err)
},
}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index ebbb4456e1..3c8ccc18eb 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -455,10 +455,9 @@ export function Tables() {
useImportTrayStore.getState().cancel(result.tableId)
void cancelTableImport(workspaceId, result.tableId, result.importId).catch(() => {})
}
- } catch (err) {
+ } catch {
+ // The hook's onError surfaces the toast; just clear the tray indicator here.
useImportTrayStore.getState().endUpload(pendingId)
- toast.error(`Failed to import ${file.name}`)
- logger.error('Error uploading CSV:', err)
}
}
From a821d4c398f5c57d89718b0ba4367cac09d3e323 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Tue, 9 Jun 2026 11:21:47 -0700
Subject: [PATCH 3/3] fix(tables): guard importId on async cancel and drop
mutation objects from deps
---
apps/sim/app/workspace/[workspaceId]/tables/tables.tsx | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index 3c8ccc18eb..19523e94a1 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -451,7 +451,11 @@ export function Tables() {
useImportTrayStore.getState().endUpload(pendingId)
// The server row drives the tray once the list refetches (mutation invalidates it).
// If canceled mid-upload, flag the real id so it's not shown and cancel server-side.
- if (result?.tableId && useImportTrayStore.getState().consumeCanceled(pendingId)) {
+ if (
+ result?.tableId &&
+ result.importId &&
+ useImportTrayStore.getState().consumeCanceled(pendingId)
+ ) {
useImportTrayStore.getState().cancel(result.tableId)
void cancelTableImport(workspaceId, result.tableId, result.importId).catch(() => {})
}
@@ -504,7 +508,8 @@ export function Tables() {
}
}
},
- [workspaceId, router, uploadCsv, importCsvAsync]
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
+ [workspaceId, router]
)
const handleListUploadCsv = useCallback(() => {