Skip to content

Commit b19b9d8

Browse files
fix(tables): validate replace before deleting rows; ignore stale replayed import events by importId
1 parent 6d2f62a commit b19b9d8

6 files changed

Lines changed: 31 additions & 5 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,16 @@ export function ImportCsvDialog({
346346
}),
347347
},
348348
{
349+
onSuccess: (data) => {
350+
// Record the import id so the tracker can ignore replayed events from a prior import.
351+
useImportTrayStore.getState().upsert({
352+
tableId: table.id,
353+
workspaceId,
354+
title: table.name,
355+
importId: data?.importId,
356+
phase: 'importing',
357+
})
358+
},
349359
onError: (err) => {
350360
useImportTrayStore.getState().dismiss(table.id)
351361
toast.error(getErrorMessage(err, 'Failed to start import'))

apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-hydrate-import-tray.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function useHydrateImportTray(workspaceId: string | undefined): void {
3737
tableId: table.id,
3838
workspaceId,
3939
title: table.name,
40+
importId: table.importId ?? undefined,
4041
phase: 'importing',
4142
rowsProcessed: table.importRowsProcessed ?? 0,
4243
error: table.importError ?? undefined,

apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/use-import-progress-tracker.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,15 @@ export function useImportProgressTracker(): void {
4343
if (event?.kind !== 'import') return
4444
const tray = useImportTrayStore.getState()
4545
const existing = tray.entries[tableId]
46-
const title = existing?.title ?? 'table'
46+
// The stream replays from the start, so the buffer can hold a *prior* import's events
47+
// for this table. Once we know this run's importId, ignore anything that doesn't match;
48+
// before we know it (brief optimistic window), don't trust a replayed terminal event.
49+
const lockedId = existing?.importId
50+
if (lockedId && event.importId !== lockedId) return
51+
if (!lockedId && (event.status === 'ready' || event.status === 'failed')) return
4752

53+
const importId = lockedId ?? event.importId
54+
const title = existing?.title ?? 'table'
4855
const rows = event.progress ?? existing?.rowsProcessed ?? 0
4956
if (event.status === 'ready') {
5057
toast.success(`Imported ${rows.toLocaleString()} rows into "${title}"`)
@@ -53,6 +60,7 @@ export function useImportProgressTracker(): void {
5360
tableId,
5461
workspaceId: existing?.workspaceId ?? '',
5562
title,
63+
importId,
5664
phase: 'ready',
5765
})
5866
setTimeout(() => {
@@ -69,6 +77,7 @@ export function useImportProgressTracker(): void {
6977
tableId,
7078
workspaceId: existing?.workspaceId ?? '',
7179
title,
80+
importId,
7281
phase: event.status,
7382
rowsProcessed: rows,
7483
total: event.total,

apps/sim/app/workspace/[workspaceId]/tables/tables.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ export function Tables() {
439439
tableId: result.tableId,
440440
workspaceId,
441441
title: file.name,
442+
importId: result.importId,
442443
phase: 'importing',
443444
rowsProcessed: 0,
444445
})

apps/sim/lib/table/import-runner.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,6 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
7878
// Stream the file rather than buffering it — a ~1M-row import must never be held in memory.
7979
const source = await downloadFileStream({ key: fileKey, context: 'workspace' })
8080

81-
// Delete only after the stream opens (a missing object rejects above) — otherwise a failed
82-
// download would wipe the table with nothing to replace it with.
83-
if (mode === 'replace') await deleteAllTableRows(tableId)
84-
8581
// Append must continue after the existing rows; create/replace start empty. Read once up
8682
// front (the import is the table's sole writer) and assign contiguous positions from it.
8783
const basePosition = mode === 'append' ? await nextImportStartPosition(tableId) : 0
@@ -161,6 +157,11 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
161157
})
162158
schema = targetSchema
163159
headerToColumn = validation.effectiveMap
160+
161+
// Replace deletes existing rows only after schema/mapping validation passes, so an
162+
// invalid or empty file fails the import with the old rows still intact (a mid-stream
163+
// insert failure after this point leaves a partial replace — replace is destructive).
164+
if (mode === 'replace') await deleteAllTableRows(tableId)
164165
}
165166

166167
const flush = async (rows: Record<string, unknown>[]) => {

apps/sim/stores/table/import-tray/store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export interface ImportTrayEntry {
1313
workspaceId: string
1414
/** Table name when known, otherwise the source file name. */
1515
title: string
16+
/** Identifies this specific import run, so replayed SSE events from a prior import of the
17+
* same table can be ignored. Known from the kickoff result / the table's `importId`. */
18+
importId?: string
1619
phase: ImportPhase
1720
rowsProcessed: number
1821
/** Estimated total rows for a determinate bar; absent until the first progress tick. */
@@ -59,6 +62,7 @@ export const useImportTrayStore = create<ImportTrayState>()(
5962
tableId: entry.tableId,
6063
workspaceId: entry.workspaceId,
6164
title: entry.title || prev?.title || 'table',
65+
importId: entry.importId ?? prev?.importId,
6266
phase: entry.phase ?? prev?.phase ?? 'importing',
6367
rowsProcessed: entry.rowsProcessed ?? prev?.rowsProcessed ?? 0,
6468
total: entry.total ?? prev?.total,

0 commit comments

Comments
 (0)