Skip to content

Commit 51b2fa3

Browse files
fix(tables): don't emit ready after cancel; honor cancel during the upload phase
1 parent f56fc2f commit 51b2fa3

6 files changed

Lines changed: 94 additions & 22 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import'
3030
import type { TableDefinition } from '@/lib/table/types'
3131
import {
3232
type CsvImportMode,
33+
cancelTableImport,
3334
useImportCsvIntoTable,
3435
useImportCsvIntoTableAsync,
3536
} from '@/hooks/queries/tables'
@@ -343,17 +344,26 @@ export function ImportCsvDialog({
343344
mode,
344345
mapping,
345346
createColumns,
346-
onProgress: (percent) =>
347+
onProgress: (percent) => {
348+
if (useImportTrayStore.getState().isCanceled(table.id)) return
347349
useImportTrayStore.getState().upsert({
348350
tableId: table.id,
349351
workspaceId,
350352
title: table.name,
351353
phase: 'importing',
352354
percent,
353-
}),
355+
})
356+
},
354357
},
355358
{
356359
onSuccess: (data) => {
360+
// Canceled mid-upload — the worker just started; cancel it instead of re-seeding.
361+
if (useImportTrayStore.getState().consumeCanceled(table.id)) {
362+
if (data?.importId) {
363+
void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {})
364+
}
365+
return
366+
}
357367
// Record the import id so the tracker can ignore replayed events from a prior import.
358368
useImportTrayStore.getState().upsert({
359369
tableId: table.id,

apps/sim/app/workspace/[workspaceId]/tables/components/import-progress-menu/import-progress-menu.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
3939
useShallow((state) => selectWorkspaceImports(state, workspaceId))
4040
)
4141
const dismiss = useImportTrayStore((state) => state.dismiss)
42+
const cancelEntry = useImportTrayStore((state) => state.cancel)
4243
const menuOpen = useImportTrayStore((state) => state.menuOpen)
4344
const setMenuOpen = useImportTrayStore((state) => state.setMenuOpen)
4445

@@ -52,9 +53,11 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
5253
const done = imports.filter((e) => e.phase === 'ready').length
5354

5455
const cancel = (entry: (typeof imports)[number]) => {
55-
// Optimistically clear it; the server flips status → the SSE `canceled` event also dismisses.
56-
dismiss(entry.tableId)
56+
// Clear it + flag canceled so an in-flight upload's callbacks don't re-create it.
57+
cancelEntry(entry.tableId)
5758
if (entry.importId) {
59+
// Worker already running — cancel it server-side now. (Otherwise the kickoff handler cancels
60+
// it once the importId is known; see the `consumeCanceled` branches.)
5861
void cancelTableImport(entry.workspaceId, entry.tableId, entry.importId).catch(() => {})
5962
}
6063
}

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
3838
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
3939
import {
40+
cancelTableImport,
4041
downloadTableExport,
4142
useCreateTable,
4243
useDeleteTable,
@@ -431,17 +432,24 @@ export function Tables() {
431432
const result = await importCsvAsync.mutateAsync({
432433
workspaceId,
433434
file,
434-
onProgress: (percent) =>
435+
onProgress: (percent) => {
436+
if (useImportTrayStore.getState().isCanceled(pendingId)) return
435437
useImportTrayStore.getState().upsert({
436438
tableId: pendingId,
437439
workspaceId,
438440
title: file.name,
439441
phase: 'importing',
440442
percent,
441-
}),
443+
})
444+
},
442445
})
443446
useImportTrayStore.getState().dismiss(pendingId)
444-
if (result?.tableId) {
447+
if (result?.tableId && useImportTrayStore.getState().consumeCanceled(pendingId)) {
448+
// Canceled mid-upload — the worker just started; cancel it server-side.
449+
void cancelTableImport(workspaceId, result.tableId, result.importId).catch(
450+
() => {}
451+
)
452+
} else if (result?.tableId) {
445453
useImportTrayStore.getState().upsert({
446454
tableId: result.tableId,
447455
workspaceId,

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

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,28 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
246246
}
247247

248248
await updateImportProgress(tableId, inserted, importId)
249-
await markImportReady(tableId, importId)
250-
void appendTableEvent({
251-
kind: 'import',
252-
tableId,
253-
importId,
254-
status: 'ready',
255-
progress: inserted,
256-
percent: 100,
257-
})
258-
logger.info(`[${requestId}] Import complete`, { tableId, fileName, mode, rows: inserted })
249+
// Only announce success if we actually won the transition — a cancel/supersede that landed
250+
// right at the end makes this a no-op, and we must not emit a false `ready`.
251+
const becameReady = await markImportReady(tableId, importId)
252+
if (becameReady) {
253+
void appendTableEvent({
254+
kind: 'import',
255+
tableId,
256+
importId,
257+
status: 'ready',
258+
progress: inserted,
259+
percent: 100,
260+
})
261+
logger.info(`[${requestId}] Import complete`, { tableId, fileName, mode, rows: inserted })
262+
} else {
263+
logger.info(
264+
`[${requestId}] Import finished but no longer owns the run (canceled/superseded)`,
265+
{
266+
tableId,
267+
importId,
268+
}
269+
)
270+
}
259271
} catch (err) {
260272
if (err instanceof ImportSupersededError) {
261273
// A newer import owns the table now — leave its status alone and just stop.

apps/sim/lib/table/service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,12 +1394,18 @@ function ownsActiveImport(tableId: string, importId: string) {
13941394
)
13951395
}
13961396

1397-
/** Marks an import complete; rows become visible. No-op unless it's still this in-flight run. */
1398-
export async function markImportReady(tableId: string, importId: string): Promise<void> {
1399-
await db
1397+
/**
1398+
* Marks an import complete; rows become visible. No-op unless it's still this in-flight run.
1399+
* Returns whether it transitioned, so the worker only emits the `ready` event when it actually
1400+
* won (and not after a cancel / supersede).
1401+
*/
1402+
export async function markImportReady(tableId: string, importId: string): Promise<boolean> {
1403+
const updated = await db
14001404
.update(userTableDefinitions)
14011405
.set({ importStatus: 'ready', importError: null, updatedAt: new Date() })
14021406
.where(ownsActiveImport(tableId, importId))
1407+
.returning({ id: userTableDefinitions.id })
1408+
return updated.length > 0
14031409
}
14041410

14051411
/**

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export type ImportTrayUpsert = Pick<ImportTrayEntry, 'tableId' | 'workspaceId' |
3535
interface ImportTrayState {
3636
/** Active + recently-terminal imports, keyed by tableId. */
3737
entries: Record<string, ImportTrayEntry>
38+
/** Tray ids canceled while still uploading (before an importId exists). The kickoff flow checks
39+
* this so its `onProgress`/`onSuccess` don't re-create a dismissed entry and cancels the worker
40+
* once the importId is known. */
41+
canceledIds: Record<string, true>
3842
/** Whether the header import dropdown is open (controlled so the start toast can open it). */
3943
menuOpen: boolean
4044
/**
@@ -44,17 +48,27 @@ interface ImportTrayState {
4448
upsert: (entry: ImportTrayUpsert) => void
4549
/** Removes a single entry (the user dismissed a terminal card). */
4650
dismiss: (tableId: string) => void
51+
/** Dismiss + flag the id canceled so an in-flight upload's callbacks don't re-create it. */
52+
cancel: (tableId: string) => void
53+
/** Whether an id is flagged canceled (read without clearing). */
54+
isCanceled: (tableId: string) => boolean
55+
/** Returns whether the id was canceled and clears the flag (one-shot, for the kickoff handler). */
56+
consumeCanceled: (tableId: string) => boolean
4757
/** Drops all terminal (`ready` / `failed`) entries for a workspace. */
4858
clearTerminalFor: (workspaceId: string) => void
4959
setMenuOpen: (open: boolean) => void
5060
reset: () => void
5161
}
5262

53-
const initialState = { entries: {} as Record<string, ImportTrayEntry>, menuOpen: false }
63+
const initialState = {
64+
entries: {} as Record<string, ImportTrayEntry>,
65+
canceledIds: {} as Record<string, true>,
66+
menuOpen: false,
67+
}
5468

5569
export const useImportTrayStore = create<ImportTrayState>()(
5670
devtools(
57-
(set) => ({
71+
(set, get) => ({
5872
...initialState,
5973

6074
upsert: (entry) =>
@@ -80,6 +94,25 @@ export const useImportTrayStore = create<ImportTrayState>()(
8094
return { entries: rest }
8195
}),
8296

97+
cancel: (tableId) =>
98+
set((state) => {
99+
const { [tableId]: _removed, ...rest } = state.entries
100+
return { entries: rest, canceledIds: { ...state.canceledIds, [tableId]: true } }
101+
}),
102+
103+
isCanceled: (tableId) => Boolean(get().canceledIds[tableId]),
104+
105+
consumeCanceled: (tableId) => {
106+
const was = Boolean(get().canceledIds[tableId])
107+
if (was) {
108+
set((state) => {
109+
const { [tableId]: _removed, ...rest } = state.canceledIds
110+
return { canceledIds: rest }
111+
})
112+
}
113+
return was
114+
},
115+
83116
clearTerminalFor: (workspaceId) =>
84117
set((state) => {
85118
const rest: Record<string, ImportTrayEntry> = {}

0 commit comments

Comments
 (0)