Skip to content

Commit 53fdcab

Browse files
feat(tables): background jobs (delete/export/backfill on trigger.dev) + tenant-scoped query performance (#4915)
* feat(tables): paginated background row-delete jobs via table_jobs * fix(tables): address review on async row-delete (filtered count, scoped optimistic clear, Cmd+A select-all, hide delete from tray) * improvement(tables): filter-aware select-all runs, delete-job read mask, keyset index + autovacuum tuning * feat(tables): run import/delete/export/backfill jobs on trigger.dev with in-process fallback * improvement(tables): raise delete page to 10k and export batch to 5k * improvement(tables): raise CSV import batch to 5k rows (param-cap bounded) * feat(tables): surface export jobs in the header tray with progress, cancel, and download * improvement(tables): surface exports as derived tables-scoped toasts instead of the import tray * Revert "improvement(tables): surface exports as derived tables-scoped toasts instead of the import tray" This reverts commit 1ea5871. * fix(tables): preserve export storage key (NoSuchKey) and unify jobs in one spinner tray * improvement(tables): jobs tray icon reflects aggregate state (spinner/check/alert) * fix(tables): restore jobs tray on the tables list (dropped in staging merge) * improvement(tables): keyset-paginate export row reads (offset paging was O(n^2) over large tables) * perf(tables): keyset pagination for grid infinite scroll Default-order row pages now cursor on (order_key, id) instead of OFFSET — each page is an index seek on tableOrderKeyIdx, where OFFSET re-scans and discards every prior row (O(N²) across deep scrolls and full drains like select-all/export-to-clipboard). Sorted views keep offset paging; the contract refines after+sort as mutually exclusive. v1 public rows API is unchanged (extends the unrefined base, omits after). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): show export in job tray immediately on kickoff The export-jobs query's poll only self-sustains once a running job is already in the cache, so a freshly kicked export stayed invisible until an SSE event or page refresh. Invalidate the tray query on kickoff success so the icon appears right away. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): surface real row/column write errors in toasts Drizzle wraps DB errors in DrizzleQueryError whose message is the failed SQL — the real cause (e.g. the row-limit trigger's RAISE) sits on .cause, so the routes' substring classification never matched and everything fell through to generic 500s ("Failed to insert row"). Add rootErrorMessage (cause-chain unwrap) and a shared rowWriteErrorResponse classifier that consolidates the per-route pattern lists and rewrites the trigger message into a friendly "Row limit exceeded — capped at N rows". Applied across the app and v1 row-write routes and the columns route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(tables): tenant-bound filtered row counts (12.7s -> 0.6s) JSONB filter predicates (->> ILIKE / range casts) are opaque to the planner: it estimates a handful of matches and picks a parallel seq scan over the entire shared user_table_rows relation — every tenant's rows — for the page-0 COUNT(*), so any non-equality filter on a large table cost 10s+ regardless of how few rows matched. Run filtered counts in a transaction with SET LOCAL enable_seqscan = off, forcing the tenant-bounded bitmap plan. Unfiltered counts keep their index-only scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(tables): tenant-bound Cmd+F search and stream its window (75s -> 2s) Same planner trap as the filtered count, compounded: the lateral jsonb_each_text ILIKE is unestimatable, so findRowMatches on a 1M-row table seq-scanned the whole 12M-row shared relation and disk-sorted ~120MB of window input (75s measured). SET LOCAL enable_seqscan=off bounds the scan to the tenant; on the default order, additionally penalizing bitmap/sort/parallel steers the planner onto the already- sorted (table_id, order_key, id) index walk so row_number() streams with no sort at all (2s measured). Flags only penalize plan shapes — a custom sort still sorts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(tables): tenant-bound sorted pages and filtered write selections Extends the seqscan fix to every remaining jsonb-predicate path, all measured on a 1M-row table in a 12M-row shared relation: - sorted page query (ORDER BY data->>'col'): 9.7s/page -> 0.76s, and deep pages stop spilling ~130MB sorts to disk - updateRowsByFilter / deleteRowsByFilter row selection: 14.4s -> bounded - delete-job worker selectRowIdPage with a filter: 12.6s/page -> bounded - dispatcher filtered-scope window walk: same shape, same fix Shared withSeqscanOff helper moves to lib/table/planner.ts (service + dispatcher both consume it; dispatcher can't import service). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(tables): tenant-scoped containment index (migration 0232) The plain GIN on user_table_rows.data matched @> candidates across every tenant sharing the relation — a hot value in someone else's table inflated everyone's equality filters (1.07M candidates fetched for a 33k-row match, lossy bitmap, 1.1s). Replace it with btree_gin (table_id, data jsonb_path_ops): the tenant intersection happens inside the index and paths are single hashed entries. Rare-equality probe 326ms -> 17ms with zero wasted candidates; unique-constraint checks and upsert conflict lookups ride the same index. The new index is smaller than the one it replaces (529MB vs 694MB on the 12M-row dev relation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(tables): tenant-bound unique-constraint checks (3.5s -> <1s per write) The unique check runs lower(data->>'col') = $1 LIMIT 1 on every insert and cell edit touching a unique column. The predicate is unestimatable and a unique (non-conflicting) value never exits early, so the planner seq-scanned all 12.3M shared-relation rows per check — 3.5s measured. Tenant-bound both the single and batch variants; the batch path sets the flag on the caller's transaction when one is supplied (SET LOCAL dies at its commit, and the statements that follow are tenant-scoped writes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(tables): tenant-bound upsert conflict lookup Same unestimatable data->>key predicate as the unique checks; an insert-path upsert has no existing match so the lookup can't exit early and seq-scans the whole shared relation. The upsert already runs in a transaction — set the planner flag on it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(tables): consolidate executor types onto planner exports service.ts kept a private DbTransaction alias and two inline typeof db | DbTransaction unions after planner.ts began exporting the canonical DbTransaction/DbExecutor — import those instead. From the /simplify review of the perf series; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tests): drop narrow schema mock override in process-contents test The local vi.mock('@sim/db/schema') stubbed only document/knowledgeBase, but the file's import graph reaches lib/table/service whose module scope now references tableJobs. The global schema mock already covers all of it — rely on it per the testing rules instead of re-mocking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): scope cancels and counts to the filtered selection (review) Addresses the open Bugbot/Greptile findings on filtered select-all: - Filtered runs no longer cancel the whole table: cancelWorkflowGroupRuns takes a filter — it stops only dispatches with that exact filter scope and only in-flight cells on matching rows (semi-join); whole-table and differently-scoped dispatches keep running, their cancelled cells skipped via cancelledAt > requestedAt. - Stop on a filtered select-all sends the filter through cancel-runs (contract + route + mutation) instead of a table-wide cancel. - runColumnBodySchema rejects rowIds + filter together (mirrors deleteTableRowsBodySchema). - Select-all delete clears the selection in onSuccess, not at click, so a failed kickoff restores both rows and selection. - Clipboard copy/cut estimates use the filter-aware total (rowTotal) instead of the whole-table rowCount. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: retrigger CI (Actions dropped the previous push events) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump api-validation route baseline to 807 (staging route + merge) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): release job claim when trigger.dev dispatch fails If tasks.trigger (or its dynamic imports) throws after markTableJobRunning, the ghost running row held the table's one-write-job slot until the stale-job janitor fired (~15-20 min of 409s). All four kickoff routes now release the claim and rethrow; the backfill runner releases and warns (a failed backfill never fails the schema change). Greptile P1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(db): squash 0232 into 0231 (one migration for the PR) Both are branch-only — no environment has applied them through the migration ledger yet — so the tenant-scoped GIN (btree_gin extension, index swap) folds into 0231_table_jobs_and_keyset. Snapshot chain re-pointed; drizzle-kit generate confirms zero drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): context-menu delete label shows the true select-all count Under select-all the context menu counted only the loaded page ("Delete 1000 rows" on a 999k-row table) while the action correctly deletes every matching row via the background job. Delete now gets its own count from the filter-aware total minus deselections; the run-action labels keep the loaded-row count since those actions act on loaded rows only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): context-menu bulk actions act on the full select-all scope Follow-up to the label fix: under select-all the context menu's Run / Re-run / Stop only acted on the loaded page of rows. They now route through the same scopes as the action bar — runs dispatch by filter (whole table when unfiltered), Stop uses the filter-scoped cancel — and all labels share one true count (filter-aware total minus deselections, locale-formatted). Like the action bar, filter-scoped runs ignore deselections (the run API has no exclusion set). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tables): exclusion set for select-all runs and stops Select-all minus deselected rows now means exactly that for every bulk action, not just delete. runColumnBodySchema and cancelTableRunsBodySchema accept excludeRowIds (bounded by MAX_EXCLUDE_ROW_IDS, select-all scope only); the dispatch scope persists it and the dispatcher window walk, eager bulk-clear, pre-run cancel, and filter/table-scoped cancel all skip excluded rows. Client threads exclusions from the selection through the action bar and the grid context menu, including the optimistic stamps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): spare excluded-row dispatches on Stop; no orphan placeholder table Two Bugbot findings on the exclusion work: - Select-all-minus-deselections Stop (no filter) cancelled every active dispatch table-wide, killing row-scoped runs on deselected rows. markActiveDispatchesCancelled now spares dispatches whose scope.rowIds are fully contained in the exclusion set (coalesce(false) keeps table-wide dispatches cancellable). - Create-mode import: a failed trigger.dev dispatch released the job claim but left the just-created placeholder table in the workspace. Archive it on the failure path (no hard-delete surface exists). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): row counts reflect a running delete everywhere A mid-delete refresh resurrected the old counts: the optimistic update stripped cached rows but left page-0 totalCount (footer / select-all label) at the old total, and list/detail counts reported raw row_count including doomed-but-not-yet-deleted rows. - onMutate now sets the active view's totalCount to the kept rows and decrements the cached detail rowCount by the doomed estimate - the kickoff persists that estimate on the job (payload.doomedCount, clamped server-side); getTableById/listTables subtract the not-yet-deleted remainder (doomedCount - rows_processed) while the delete runs, so refetched counts match the read path's delete mask Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * copy(tables): drop background mention from delete confirm Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): clear select-all immediately when a delete kicks off The header checkbox lingered as a minus over the optimistically-emptied grid: rowSelectionCoversAll treats zero rows as not-covered, and the selection clear waited for the kickoff's onSuccess. Clear at click (failed kickoffs visibly restore rows + toast; re-selecting is cheap) and render an empty grid's header checkbox unchecked regardless — a selection over zero rows is vacuous. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tables): export takes the async path while a delete job runs The sync/async export choice reads rowCount, which is a doomed-estimate- adjusted number during a running delete (and the estimate is client- supplied) — an overstated estimate could route a still-large masked set through the synchronous stream. Mid-delete exports now always run as a job: safe at any size, and exports bypass the one-job-per-table gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(build): stop uploads setup from sweeping the project into route graphs next build (Turbopack) failed with "Two or more assets with different content were emitted to the same output path" on the server-root chunk. Root cause: setup.server.ts's unscoped path.resolve(process.cwd()) made node-file-tracing sweep the entire project — next.config.ts included — into every route graph reaching lib/uploads (the files/upload route and, since the export job, the export-async path). Two producers emitted the swept config into same-named chunks; staging's latest commits made their contents diverge and the names collided. Annotate the path derivation with turbopackIgnore per the NFT warning's own remediation — the build passes and all ~390 "unexpected file in NFT list" warnings disappear. Also inline the releaseJobClaim dynamic imports in the kickoff routes to plain static imports — service is already statically imported there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 75b3efa commit 53fdcab

74 files changed

Lines changed: 20902 additions & 992 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/cron/cleanup-stale-executions/route.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { asyncJobs, db } from '@sim/db'
2-
import { userTableDefinitions, workflowExecutionLogs } from '@sim/db/schema'
2+
import { tableJobs, workflowExecutionLogs } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
55
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
@@ -8,12 +8,15 @@ import { verifyCronAuth } from '@/lib/auth/internal'
88
import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs'
99
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { deleteFile } from '@/lib/uploads/core/storage-service'
1112

1213
const logger = createLogger('CleanupStaleExecutions')
1314

1415
const STALE_THRESHOLD_MS = getMaxExecutionTimeout() + 5 * 60 * 1000
1516
const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000)
1617
const MAX_INT32 = 2_147_483_647
18+
/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */
19+
const TABLE_JOB_RETENTION_HOURS = 24
1720

1821
export const GET = withRouteHandler(async (request: NextRequest) => {
1922
try {
@@ -110,33 +113,56 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
110113
})
111114
}
112115

113-
// Mark stale table imports as failed. Imports run detached on the web container and
114-
// are lost if the pod is killed mid-load. `updatedAt` is bumped by progress updates, so
115-
// an `importing` table with no recent update has stalled (not merely slow). Rows are
116-
// left in place (no rollback); the user re-imports.
116+
// Mark stale table jobs (import or delete) as failed. Jobs run detached on the web container
117+
// and are lost if the pod is killed mid-run. `updated_at` is bumped by progress updates, so a
118+
// `running` job with no recent update has stalled (not merely slow). Committed work is left in
119+
// place (no rollback); the user retries. Also prune long-settled terminal jobs so the table
120+
// doesn't grow unbounded (the latest job per table is what list/detail reads surface).
117121
let staleImportsMarkedFailed = 0
118122
try {
123+
const now = new Date()
119124
const staleImports = await db
120-
.update(userTableDefinitions)
125+
.update(tableJobs)
121126
.set({
122-
importStatus: 'failed',
123-
importError: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`,
124-
updatedAt: new Date(),
127+
status: 'failed',
128+
error: `Job terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`,
129+
completedAt: now,
130+
updatedAt: now,
125131
})
132+
.where(and(eq(tableJobs.status, 'running'), lt(tableJobs.updatedAt, staleThreshold)))
133+
.returning({ id: tableJobs.id })
134+
135+
staleImportsMarkedFailed = staleImports.length
136+
if (staleImportsMarkedFailed > 0) {
137+
logger.info(`Marked ${staleImportsMarkedFailed} stale table jobs as failed`)
138+
}
139+
140+
const terminalRetention = new Date(Date.now() - TABLE_JOB_RETENTION_HOURS * 60 * 60 * 1000)
141+
const pruned = await db
142+
.delete(tableJobs)
126143
.where(
127144
and(
128-
eq(userTableDefinitions.importStatus, 'importing'),
129-
lt(userTableDefinitions.updatedAt, staleThreshold)
145+
inArray(tableJobs.status, ['ready', 'failed', 'canceled']),
146+
lt(tableJobs.updatedAt, terminalRetention)
130147
)
131148
)
132-
.returning({ id: userTableDefinitions.id })
133-
134-
staleImportsMarkedFailed = staleImports.length
135-
if (staleImportsMarkedFailed > 0) {
136-
logger.info(`Marked ${staleImportsMarkedFailed} stale table imports as failed`)
149+
.returning({ type: tableJobs.type, payload: tableJobs.payload })
150+
151+
// Pruned export jobs carry the generated file's storage key — delete the file with the job
152+
// so the exports prefix doesn't accumulate. Best-effort: a miss just orphans one object.
153+
for (const job of pruned) {
154+
if (job.type !== 'export') continue
155+
const resultKey = (job.payload as { resultKey?: string } | null)?.resultKey
156+
if (!resultKey) continue
157+
await deleteFile({ key: resultKey, context: 'workspace' }).catch((err) => {
158+
logger.warn('Failed to delete pruned export file', {
159+
resultKey,
160+
error: toError(err).message,
161+
})
162+
})
137163
}
138164
} catch (error) {
139-
logger.error('Failed to clean up stale table imports:', {
165+
logger.error('Failed to clean up stale table jobs:', {
140166
error: toError(error).message,
141167
})
142168
}

apps/sim/app/api/table/[tableId]/cancel-runs/route.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns'
9-
import { accessError, checkAccess } from '@/app/api/table/utils'
9+
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1010

1111
const logger = createLogger('TableCancelRunsAPI')
1212

@@ -32,7 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
3232
const parsed = await parseRequest(cancelTableRunsContract, request, { params })
3333
if (!parsed.success) return parsed.response
3434
const { tableId } = parsed.data.params
35-
const { workspaceId, scope, rowId } = parsed.data.body
35+
const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body
3636

3737
const result = await checkAccess(tableId, authResult.userId, 'write')
3838
if (!result.ok) return accessError(result, requestId, tableId)
@@ -42,7 +42,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
4242
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
4343
}
4444

45-
const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined)
45+
const filterError = tableFilterError(filter, table.schema.columns)
46+
if (filterError) return filterError
47+
48+
const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, {
49+
filter,
50+
excludeRowIds,
51+
})
4652
logger.info(
4753
`[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${
4854
rowId ? ` rowId=${rowId}` : ''

apps/sim/app/api/table/[tableId]/columns/route.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
updateColumnConstraints,
1818
updateColumnType,
1919
} from '@/lib/table'
20-
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
20+
import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils'
2121

2222
const logger = createLogger('TableColumnsAPI')
2323

@@ -63,13 +63,17 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
6363
return validationErrorResponse(error, 'Invalid request data')
6464
}
6565

66-
if (error instanceof Error) {
67-
if (error.message.includes('already exists') || error.message.includes('maximum column')) {
68-
return NextResponse.json({ error: error.message }, { status: 400 })
69-
}
70-
if (error.message === 'Table not found') {
71-
return NextResponse.json({ error: error.message }, { status: 404 })
72-
}
66+
const msg = rootErrorMessage(error)
67+
if (
68+
msg.includes('already exists') ||
69+
msg.includes('maximum column') ||
70+
msg.includes('Invalid column') ||
71+
msg.includes('exceeds maximum')
72+
) {
73+
return NextResponse.json({ error: msg }, { status: 400 })
74+
}
75+
if (msg === 'Table not found') {
76+
return NextResponse.json({ error: msg }, { status: 404 })
7377
}
7478

7579
logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error)
@@ -146,22 +150,21 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
146150
return validationErrorResponse(error, 'Invalid request data')
147151
}
148152

149-
if (error instanceof Error) {
150-
const msg = error.message
151-
if (msg.includes('not found') || msg.includes('Table not found')) {
152-
return NextResponse.json({ error: msg }, { status: 404 })
153-
}
154-
if (
155-
msg.includes('already exists') ||
156-
msg.includes('Cannot delete the last column') ||
157-
msg.includes('Cannot set column') ||
158-
msg.includes('Invalid column') ||
159-
msg.includes('exceeds maximum') ||
160-
msg.includes('incompatible') ||
161-
msg.includes('duplicate')
162-
) {
163-
return NextResponse.json({ error: msg }, { status: 400 })
164-
}
153+
const msg = rootErrorMessage(error)
154+
if (msg.includes('not found') || msg.includes('Table not found')) {
155+
return NextResponse.json({ error: msg }, { status: 404 })
156+
}
157+
if (
158+
msg.includes('already exists') ||
159+
msg.includes('Cannot delete the last column') ||
160+
msg.includes('Cannot set column') ||
161+
msg.includes('Cannot set unique column') ||
162+
msg.includes('Invalid column') ||
163+
msg.includes('exceeds maximum') ||
164+
msg.includes('incompatible') ||
165+
msg.includes('duplicate')
166+
) {
167+
return NextResponse.json({ error: msg }, { status: 400 })
165168
}
166169

167170
logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error)
@@ -211,13 +214,12 @@ export const DELETE = withRouteHandler(
211214
return validationErrorResponse(error, 'Invalid request data')
212215
}
213216

214-
if (error instanceof Error) {
215-
if (error.message.includes('not found') || error.message === 'Table not found') {
216-
return NextResponse.json({ error: error.message }, { status: 404 })
217-
}
218-
if (error.message.includes('Cannot delete') || error.message.includes('last column')) {
219-
return NextResponse.json({ error: error.message }, { status: 400 })
220-
}
217+
const msg = rootErrorMessage(error)
218+
if (msg.includes('not found') || msg === 'Table not found') {
219+
return NextResponse.json({ error: msg }, { status: 404 })
220+
}
221+
if (msg.includes('Cannot delete') || msg.includes('last column')) {
222+
return NextResponse.json({ error: msg }, { status: 400 })
221223
}
222224

223225
logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error)

apps/sim/app/api/table/[tableId]/columns/run/route.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { runWorkflowColumn } from '@/lib/table/workflow-columns'
9-
import { accessError, checkAccess } from '@/app/api/table/utils'
9+
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
1010

1111
const logger = createLogger('TableRunColumnAPI')
1212

@@ -25,16 +25,23 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
2525
const parsed = await parseRequest(runColumnContract, request, { params })
2626
if (!parsed.success) return parsed.response
2727
const { tableId } = parsed.data.params
28-
const { workspaceId, groupIds, runMode, rowIds, limit } = parsed.data.body
28+
const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } =
29+
parsed.data.body
2930
const access = await checkAccess(tableId, auth.userId, 'write')
3031
if (!access.ok) return accessError(access, requestId, tableId)
3132

33+
// Validate the filter up front (the dispatcher reuses it) so a bad field fails fast.
34+
const filterError = tableFilterError(filter, access.table.schema.columns)
35+
if (filterError) return filterError
36+
3237
const { dispatchId } = await runWorkflowColumn({
3338
tableId,
3439
workspaceId,
3540
groupIds,
3641
mode: runMode,
3742
rowIds,
43+
filter,
44+
excludeRowIds,
3845
limit,
3946
requestId,
4047
triggeredByUserId: auth.userId,

0 commit comments

Comments
 (0)