Skip to content

Commit de7df1f

Browse files
refactor(tables): drive import tray by polling derived from server, not SSE
Import progress no longer holds an SSE connection per importing table. The tray now derives its importing rows live from the table list (React Query), polled only while an import is in flight; the table detail page keeps its own cell-state SSE for grid refresh. - store holds only client-only state now: optimistic uploads, which terminal completions to surface this session, canceled ids, menu open — no copied importStatus/rowsProcessed. - useWorkspaceImports is the single source: polls via a data-predicate refetchInterval, derives rows, and fires completion toasts on the importing -> terminal transition. - kickoff handlers use startUpload/setUploadPercent/endUpload; the invalidated list refetch surfaces the server row and polling takes over. - removes use-hydrate-import-tray + use-import-progress-tracker (folded in). - trims over-verbose comments across the import paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5fa7391 commit de7df1f

11 files changed

Lines changed: 234 additions & 389 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,8 @@ export function useTableEventStream({
236236
}
237237
: prev
238238
)
239-
// The header tray + completion toast are owned by `useImportProgressTracker` (mounted on
240-
// every page). Here we only keep the detail cache + grid in sync.
239+
// The header tray + completion toast are owned by `useImportTrayPoll` (mounted on every
240+
// page). Here we only keep the detail cache + grid in sync.
241241
// Live-fill: rows are real as each batch commits. Coalesce the per-tick row
242242
// refetches via a debounce; on the terminal event refetch rows + the
243243
// definition immediately (the worker may have rewritten the schema).

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

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,8 @@ const logger = createLogger('ImportCsvDialog')
4040

4141
const MAX_SAMPLE_ROWS = 5
4242
const 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-
*/
43+
/** Bytes read for the preview/mapping. We never parse the whole file client-side — the importer
44+
* streams it server-side and the DB trigger enforces the row limit. */
4945
const CSV_PREVIEW_BYTES = 512 * 1024
5046
/**
5147
* Sentinel value for the "Do not import" option in the mapping combobox. The
@@ -110,11 +106,7 @@ interface ParsedCsv {
110106
sampleRows: Record<string, unknown>[]
111107
}
112108

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-
*/
109+
/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */
118110
async function parseCsvPreview(file: File, delimiter: ',' | '\t') {
119111
const sliced = file.size > CSV_PREVIEW_BYTES
120112
const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file
@@ -329,12 +321,10 @@ export function ImportCsvDialog({
329321
// close the dialog immediately so the indicator is visible during the upload, then run
330322
// the upload + kickoff in the background (don't block the dialog on it).
331323
if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) {
332-
useImportTrayStore.getState().upsert({
333-
tableId: table.id,
324+
useImportTrayStore.getState().startUpload({
325+
uploadId: table.id,
334326
workspaceId,
335-
title: table.name,
336-
phase: 'importing',
337-
rowsProcessed: 0,
327+
title: parsed.file.name,
338328
})
339329
onOpenChange(false)
340330
toast({
@@ -353,39 +343,21 @@ export function ImportCsvDialog({
353343
mapping,
354344
createColumns,
355345
onProgress: (percent) => {
356-
if (useImportTrayStore.getState().isCanceled(table.id)) return
357-
useImportTrayStore.getState().upsert({
358-
tableId: table.id,
359-
workspaceId,
360-
title: table.name,
361-
phase: 'importing',
362-
percent,
363-
})
346+
useImportTrayStore.getState().setUploadPercent(table.id, percent)
364347
},
365348
},
366349
{
367350
onSuccess: (data) => {
368-
// Canceled mid-upload — the worker just started; cancel it instead of re-seeding.
369-
if (useImportTrayStore.getState().consumeCanceled(table.id)) {
370-
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)
374-
void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {})
375-
}
376-
return
351+
useImportTrayStore.getState().endUpload(table.id)
352+
// The server row drives the tray once the list refetches. If canceled mid-upload, flag
353+
// the id so it's not shown and cancel the worker server-side.
354+
if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) {
355+
useImportTrayStore.getState().cancel(table.id)
356+
void cancelTableImport(workspaceId, table.id, data.importId).catch(() => {})
377357
}
378-
// Record the import id so the tracker can ignore replayed events from a prior import.
379-
useImportTrayStore.getState().upsert({
380-
tableId: table.id,
381-
workspaceId,
382-
title: table.name,
383-
importId: data?.importId,
384-
phase: 'importing',
385-
})
386358
},
387359
onError: (err) => {
388-
useImportTrayStore.getState().dismiss(table.id)
360+
useImportTrayStore.getState().endUpload(table.id)
389361
toast.error(getErrorMessage(err, 'Failed to start import'))
390362
logger.error('Async CSV import failed to start', err)
391363
},

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

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use client'
22

3-
import { useShallow } from 'zustand/react/shallow'
43
import {
54
Button,
65
DropdownMenu,
@@ -10,10 +9,9 @@ import {
109
} from '@/components/emcn'
1110
import { Upload } from '@/components/emcn/icons'
1211
import { cancelTableImport } from '@/hooks/queries/tables'
13-
import { selectWorkspaceImports, useImportTrayStore } from '@/stores/table/import-tray/store'
12+
import { useImportTrayStore } from '@/stores/table/import-tray/store'
1413
import { getImportStage } from './import-stage'
15-
import { useHydrateImportTray } from './use-hydrate-import-tray'
16-
import { useImportProgressTracker } from './use-import-progress-tracker'
14+
import { type ImportRow, useWorkspaceImports } from './use-workspace-imports'
1715

1816
interface ImportProgressMenuProps {
1917
workspaceId: string | undefined
@@ -23,42 +21,27 @@ interface ImportProgressMenuProps {
2321

2422
/**
2523
* Header affordance for background CSV imports: a clickable `{done}/{total}` count that opens a
26-
* dropdown of per-import progress rows. Renders nothing when there are no tracked imports. The
27-
* single import-progress surface for both the tables list and the in-table view.
24+
* dropdown of per-import progress rows. Renders nothing when there are no imports. The single
25+
* import-progress surface for both the tables list and the in-table view.
2826
*/
2927
export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuProps) {
30-
// Re-seed the (in-memory) tray from server truth so the indicator survives a refresh,
31-
// then keep it live on every page by subscribing to each active import's event stream.
32-
useHydrateImportTray(workspaceId)
33-
useImportProgressTracker()
34-
35-
// `selectWorkspaceImports` builds a fresh array each call; `useShallow` compares its
36-
// contents so a re-render is triggered only when the entries actually change (without it
37-
// the new reference loops forever).
38-
const allImports = useImportTrayStore(
39-
useShallow((state) => selectWorkspaceImports(state, workspaceId))
40-
)
28+
const imports = useWorkspaceImports(workspaceId, tableId)
4129
const dismiss = useImportTrayStore((state) => state.dismiss)
42-
const cancelEntry = useImportTrayStore((state) => state.cancel)
30+
const cancelId = useImportTrayStore((state) => state.cancel)
4331
const menuOpen = useImportTrayStore((state) => state.menuOpen)
4432
const setMenuOpen = useImportTrayStore((state) => state.setMenuOpen)
4533

46-
// Inside a table, scope the indicator to that table's import only; on the list view show
47-
// every active import in the workspace.
48-
const imports = tableId ? allImports.filter((e) => e.tableId === tableId) : allImports
49-
5034
if (imports.length === 0) return null
5135

5236
const total = imports.length
5337
const done = imports.filter((e) => e.phase === 'ready').length
5438

55-
const cancel = (entry: (typeof imports)[number]) => {
56-
// Clear it + flag canceled so an in-flight upload's callbacks don't re-create it.
57-
cancelEntry(entry.tableId)
58-
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.)
61-
void cancelTableImport(entry.workspaceId, entry.tableId, entry.importId).catch(() => {})
39+
const cancel = (row: ImportRow) => {
40+
cancelId(row.id)
41+
// Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by
42+
// the kickoff handler once its importId is known; see the `consumeCanceled` branches.)
43+
if (row.importId) {
44+
void cancelTableImport(row.workspaceId, row.id, row.importId).catch(() => {})
6245
}
6346
}
6447

@@ -73,17 +56,17 @@ export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuP
7356
</Button>
7457
</DropdownMenuTrigger>
7558
<DropdownMenuContent align='end' className='min-w-[320px] max-w-[420px] gap-0 p-1'>
76-
{imports.map((entry) => {
77-
const stage = getImportStage(entry)
59+
{imports.map((row) => {
60+
const stage = getImportStage(row)
7861
return (
7962
<ProgressItem
80-
key={entry.tableId}
63+
key={row.id}
8164
status={stage.status}
8265
title={stage.title}
8366
meta={stage.meta}
8467
detail={stage.detail}
85-
onCancel={entry.phase === 'importing' ? () => cancel(entry) : undefined}
86-
onDismiss={stage.dismissible ? () => dismiss(entry.tableId) : undefined}
68+
onCancel={row.phase === 'importing' ? () => cancel(row) : undefined}
69+
onDismiss={stage.dismissible ? () => dismiss(row.id) : undefined}
8770
/>
8871
)
8972
})}

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ImportTrayEntry } from '@/stores/table/import-tray/store'
1+
import type { ImportRow } from './use-workspace-imports'
22

33
type ProgressStatus = 'pending' | 'success' | 'error'
44

@@ -17,11 +17,12 @@ export interface ImportStageView {
1717
/**
1818
* Maps a tray entry to the stage shown in the import dropdown. The single place the import
1919
* stages (Uploading → Processing → Imported / Failed) are defined; the row component just
20-
* renders the returned slots, so every stage looks consistent: `{status} {name}` with a
21-
* byte-based percent on the right and the row count underneath. The percent comes straight from
22-
* `entry.percent` (exact, monotonic) rather than an estimated row fraction.
20+
* renders the returned slots, so every stage looks consistent: `{status} {name}`. While
21+
* uploading, the right slot shows the byte-based upload percent (from the client XHR). Once the
22+
* server is processing we only know the committed row count (polled from the table row), so the
23+
* detail line reads `{rows} rows` with no percent.
2324
*/
24-
export function getImportStage(entry: ImportTrayEntry): ImportStageView {
25+
export function getImportStage(entry: ImportRow): ImportStageView {
2526
const rows = entry.rowsProcessed.toLocaleString()
2627
const name = entry.title
2728
const meta = typeof entry.percent === 'number' ? `${entry.percent}%` : undefined
@@ -49,7 +50,6 @@ export function getImportStage(entry: ImportTrayEntry): ImportStageView {
4950
return {
5051
status: 'pending',
5152
title: `Processing ${name}`,
52-
meta,
5353
detail: `${rows} rows`,
5454
dismissible: false,
5555
}

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

Lines changed: 0 additions & 71 deletions
This file was deleted.

0 commit comments

Comments
 (0)