diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index ea5ca453968..7be6e56b32d 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -69,7 +69,18 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" - bun run db:push --force + # `--force` only suppresses the data-loss confirm, not drizzle's + # rename-vs-drop prompt, which fires (and crashes, no TTY) when a + # diff both adds and drops tables/columns at once. Turn that opaque + # crash into an actionable failure instead of a bare stack trace. + push_output="$(bun run db:push --force 2>&1)" && push_status=0 || push_status=$? + echo "$push_output" + if [ "$push_status" -ne 0 ]; then + if printf '%s' "$push_output" | grep -q 'Interactive prompts require a TTY'; then + echo "::error title=Dev schema push needs manual reconciliation::drizzle-kit push hit an interactive rename/drop prompt that CI cannot answer. The dev DB has drifted from schema.ts: it still holds table(s)/column(s) the schema no longer declares while the schema also adds new ones, so drizzle cannot tell a rename from a drop+create. Fix: drop the stale objects on the dev DB to match schema.ts — the same DROPs the latest versioned migration already applied to staging/prod (grep packages/db/migrations for the most recent DROP TABLE / DROP COLUMN) — then re-run this workflow. --force cannot bypass this prompt." + fi + exit "$push_status" + fi else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 99c395d644b..9f5bb1b868e 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -1,5 +1,5 @@ import { asyncJobs, db } from '@sim/db' -import { userTableDefinitions, workflowExecutionLogs } from '@sim/db/schema' +import { tableJobs, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, lt, sql } from 'drizzle-orm' @@ -8,12 +8,15 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { deleteFile } from '@/lib/uploads/core/storage-service' const logger = createLogger('CleanupStaleExecutions') const STALE_THRESHOLD_MS = getMaxExecutionTimeout() + 5 * 60 * 1000 const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000) const MAX_INT32 = 2_147_483_647 +/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */ +const TABLE_JOB_RETENTION_HOURS = 24 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -110,33 +113,56 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }) } - // Mark stale table imports as failed. Imports run detached on the web container and - // are lost if the pod is killed mid-load. `updatedAt` is bumped by progress updates, so - // an `importing` table with no recent update has stalled (not merely slow). Rows are - // left in place (no rollback); the user re-imports. + // Mark stale table jobs (import or delete) as failed. Jobs run detached on the web container + // and are lost if the pod is killed mid-run. `updated_at` is bumped by progress updates, so a + // `running` job with no recent update has stalled (not merely slow). Committed work is left in + // place (no rollback); the user retries. Also prune long-settled terminal jobs so the table + // doesn't grow unbounded (the latest job per table is what list/detail reads surface). let staleImportsMarkedFailed = 0 try { + const now = new Date() const staleImports = await db - .update(userTableDefinitions) + .update(tableJobs) .set({ - importStatus: 'failed', - importError: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`, - updatedAt: new Date(), + status: 'failed', + error: `Job terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`, + completedAt: now, + updatedAt: now, }) + .where(and(eq(tableJobs.status, 'running'), lt(tableJobs.updatedAt, staleThreshold))) + .returning({ id: tableJobs.id }) + + staleImportsMarkedFailed = staleImports.length + if (staleImportsMarkedFailed > 0) { + logger.info(`Marked ${staleImportsMarkedFailed} stale table jobs as failed`) + } + + const terminalRetention = new Date(Date.now() - TABLE_JOB_RETENTION_HOURS * 60 * 60 * 1000) + const pruned = await db + .delete(tableJobs) .where( and( - eq(userTableDefinitions.importStatus, 'importing'), - lt(userTableDefinitions.updatedAt, staleThreshold) + inArray(tableJobs.status, ['ready', 'failed', 'canceled']), + lt(tableJobs.updatedAt, terminalRetention) ) ) - .returning({ id: userTableDefinitions.id }) - - staleImportsMarkedFailed = staleImports.length - if (staleImportsMarkedFailed > 0) { - logger.info(`Marked ${staleImportsMarkedFailed} stale table imports as failed`) + .returning({ type: tableJobs.type, payload: tableJobs.payload }) + + // Pruned export jobs carry the generated file's storage key — delete the file with the job + // so the exports prefix doesn't accumulate. Best-effort: a miss just orphans one object. + for (const job of pruned) { + if (job.type !== 'export') continue + const resultKey = (job.payload as { resultKey?: string } | null)?.resultKey + if (!resultKey) continue + await deleteFile({ key: resultKey, context: 'workspace' }).catch((err) => { + logger.warn('Failed to delete pruned export file', { + resultKey, + error: toError(err).message, + }) + }) } } catch (error) { - logger.error('Failed to clean up stale table imports:', { + logger.error('Failed to clean up stale table jobs:', { error: toError(error).message, }) } diff --git a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts index be89633d7e9..ce656d6be50 100644 --- a/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/table/[tableId]/cancel-runs/route.ts @@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' const logger = createLogger('TableCancelRunsAPI') @@ -32,7 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const parsed = await parseRequest(cancelTableRunsContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, scope, rowId } = parsed.data.body + const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body const result = await checkAccess(tableId, authResult.userId, 'write') if (!result.ok) return accessError(result, requestId, tableId) @@ -42,7 +42,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined) + const filterError = tableFilterError(filter, table.schema.columns) + if (filterError) return filterError + + const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { + filter, + excludeRowIds, + }) logger.info( `[${requestId}] cancel-runs: tableId=${tableId} scope=${scope}${ rowId ? ` rowId=${rowId}` : '' diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index 6b87c84f644..7eecb5ee466 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -17,7 +17,7 @@ import { updateColumnConstraints, updateColumnType, } from '@/lib/table' -import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' +import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils' const logger = createLogger('TableColumnsAPI') @@ -63,13 +63,17 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } + const msg = rootErrorMessage(error) + if ( + msg.includes('already exists') || + msg.includes('maximum column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) + } + if (msg === 'Table not found') { + return NextResponse.json({ error: msg }, { status: 404 }) } logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) @@ -146,22 +150,21 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } + const msg = rootErrorMessage(error) + if (msg.includes('not found') || msg.includes('Table not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if ( + msg.includes('already exists') || + msg.includes('Cannot delete the last column') || + msg.includes('Cannot set column') || + msg.includes('Cannot set unique column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') || + msg.includes('incompatible') || + msg.includes('duplicate') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) } logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) @@ -211,13 +214,12 @@ export const DELETE = withRouteHandler( return validationErrorResponse(error, 'Invalid request data') } - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) - } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } + const msg = rootErrorMessage(error) + if (msg.includes('not found') || msg === 'Table not found') { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if (msg.includes('Cannot delete') || msg.includes('last column')) { + return NextResponse.json({ error: msg }, { status: 400 }) } logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index 341f58662b0..00856ae4a1a 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -6,7 +6,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' const logger = createLogger('TableRunColumnAPI') @@ -25,16 +25,23 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const parsed = await parseRequest(runColumnContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, groupIds, runMode, rowIds, limit } = parsed.data.body + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = + parsed.data.body const access = await checkAccess(tableId, auth.userId, 'write') if (!access.ok) return accessError(access, requestId, tableId) + // Validate the filter up front (the dispatcher reuses it) so a bad field fails fast. + const filterError = tableFilterError(filter, access.table.schema.columns) + if (filterError) return filterError + const { dispatchId } = await runWorkflowColumn({ tableId, workspaceId, groupIds, mode: runMode, rowIds, + filter, + excludeRowIds, limit, requestId, triggeredByUserId: auth.userId, diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts new file mode 100644 index 00000000000..9565725c8a6 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts @@ -0,0 +1,213 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { + mockCheckAccess, + mockMarkTableJobRunning, + mockReleaseJobClaim, + mockRunTableDelete, + mockTableFilterError, + mockTasksTrigger, + flags, +} = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockReleaseJobClaim: vi.fn(), + mockRunTableDelete: vi.fn(), + mockTableFilterError: vi.fn(), + mockTasksTrigger: vi.fn(), + flags: { triggerDev: false }, +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('job-id-xyz'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) +vi.mock('@/lib/table/service', () => ({ + markTableJobRunning: mockMarkTableJobRunning, + releaseJobClaim: mockReleaseJobClaim, +})) +vi.mock('@/lib/table/delete-runner', () => ({ runTableDelete: mockRunTableDelete })) +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isTriggerDevEnabled() { + return flags.triggerDev + }, +})) +vi.mock('@/background/table-delete', () => ({ tableDeleteTask: { id: 'table-delete' } })) +vi.mock('@trigger.dev/sdk', () => ({ + tasks: { trigger: mockTasksTrigger }, + task: (config: unknown) => config, +})) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: (_label: string, work: () => Promise) => { + void work() + }, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + tableFilterError: mockTableFilterError, + } +}) + +import { POST } from '@/app/api/table/[tableId]/delete-async/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'status', type: 'string' }] }, + metadata: null, + rowCount: 1000, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/delete-async`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { + workspaceId: 'workspace-1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], +} + +describe('POST /api/table/[tableId]/delete-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockRunTableDelete.mockResolvedValue(undefined) + mockTableFilterError.mockReturnValue(null) + mockTasksTrigger.mockResolvedValue({ id: 'run_1' }) + flags.triggerDev = false + }) + + it('claims the job slot and kicks off the delete worker with filter + exclusions', async () => { + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_1', jobId: 'job-id-xyz' }) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', 'job-id-xyz', 'delete', { + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(String), + }) + expect(mockRunTableDelete).toHaveBeenCalledWith( + expect.objectContaining({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + workspaceId: 'workspace-1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(Date), + }) + ) + }) + + it('allows a whole-table delete with no filter', async () => { + const response = await makeRequest({ workspaceId: 'workspace-1' }) + expect(response.status).toBe(200) + expect(mockRunTableDelete).toHaveBeenCalledWith( + expect.objectContaining({ filter: undefined, cutoff: expect.any(Date) }) + ) + }) + + it('returns 409 when a job is already in progress (claim lost)', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + const response = await makeRequest(validBody) + expect(response.status).toBe(409) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 on an invalid filter without claiming the slot', async () => { + mockTableFilterError.mockReturnValue(NextResponse.json({ error: 'bad field' }, { status: 400 })) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 when the table is archived', async () => { + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ archivedAt: new Date() }) }) + const response = await makeRequest(validBody) + expect(response.status).toBe(400) + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + }) + + it('routes through trigger.dev (ISO cutoff, tagged) when the flag is on', async () => { + flags.triggerDev = true + const response = await makeRequest(validBody) + + expect(response.status).toBe(200) + expect(mockRunTableDelete).not.toHaveBeenCalled() + expect(mockTasksTrigger).toHaveBeenCalledWith( + 'table-delete', + expect.objectContaining({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + filter: { status: 'archived' }, + excludeRowIds: ['row_keep'], + cutoff: expect.any(String), + }), + { tags: ['tableId:tbl_1', 'jobId:job-id-xyz'] } + ) + }) + + it('releases the job claim when the trigger.dev dispatch fails (no ghost running job)', async () => { + flags.triggerDev = true + mockTasksTrigger.mockRejectedValueOnce(new Error('trigger.dev unreachable')) + + const response = await makeRequest(validBody) + + expect(response.status).toBe(500) + expect(mockReleaseJobClaim).toHaveBeenCalledWith('tbl_1', 'job-id-xyz') + expect(mockRunTableDelete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.ts new file mode 100644 index 00000000000..7dcd8c37676 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.ts @@ -0,0 +1,128 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { deleteTableRowsAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/service' +import type { TableDeleteJobPayload } from '@/lib/table/types' +import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils' + +const logger = createLogger('TableDeleteAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/delete-async + * + * Kicks off a background "select all" delete: the client sends the active filter (and an optional + * exclusion set) instead of every row id. Claims the table's single job slot (mutually exclusive + * with imports), captures a `created_at` cutoff so rows inserted while the job runs survive, then + * runs the paginated delete worker detached. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const userId = authResult.userId + + const parsed = await parseRequest(deleteTableRowsAsyncContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, filter, excludeRowIds, estimatedCount } = parsed.data.body + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + const { table } = access + + if (table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + if (table.archivedAt) { + return NextResponse.json({ error: 'Cannot delete from an archived table' }, { status: 400 }) + } + + // Validate the filter up front so the caller gets immediate feedback (the worker reuses it). + const filterError = tableFilterError(filter, table.schema.columns) + if (filterError) return filterError + + // Rows inserted after this instant are spared (created_at <= cutoff in the worker). + const cutoff = new Date() + + // Atomically claim the job slot — one background job per table, so this also blocks while an + // import is in flight (and vice versa). The scope is persisted to the job's payload so read + // paths can mask the doomed rows while the job runs (see `pendingDeleteMask`). + const jobId = generateId() + const payload: TableDeleteJobPayload = { + filter, + excludeRowIds, + cutoff: cutoff.toISOString(), + // Clamp the client's display estimate to reality so a stale/bogus value + // can't drive counts negative or hide more than the table holds. + ...(estimatedCount != null ? { doomedCount: Math.min(estimatedCount, table.rowCount) } : {}), + } + const claimed = await markTableJobRunning(tableId, jobId, 'delete', payload) + if (!claimed) { + return NextResponse.json( + { error: 'A job is already in progress for this table' }, + { status: 409 } + ) + } + + if (isTriggerDevEnabled) { + // Trigger.dev runs the delete outside the web container (survives deploys) and retries — + // safe: the keyset + cutoff walk just deletes whatever remains. + try { + const [{ tableDeleteTask }, { tasks }] = await Promise.all([ + import('@/background/table-delete'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger( + 'table-delete', + { jobId, tableId, workspaceId, filter, excludeRowIds, cutoff: cutoff.toISOString() }, + { tags: [`tableId:${tableId}`, `jobId:${jobId}`] } + ) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-delete', () => + runTableDelete({ + jobId, + tableId, + workspaceId, + filter, + excludeRowIds, + cutoff, + }).catch(async (error) => { + // No retry machinery on the detached path — fail the job immediately. + await markTableDeleteFailed(tableId, jobId, error) + throw error + }) + ) + } + + logger.info(`[${requestId}] Async row delete started`, { + tableId, + jobId, + hasFilter: Boolean(filter), + excluded: excludeRowIds?.length ?? 0, + }) + return NextResponse.json({ success: true, data: { tableId, jobId } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts new file mode 100644 index 00000000000..177e02abf37 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockMarkTableJobRunning, mockRunTableExport } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockMarkTableJobRunning: vi.fn(), + mockRunTableExport: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn().mockReturnValue('job-id-xyz'), + generateShortId: vi.fn().mockReturnValue('short-id'), +})) +vi.mock('@/lib/table/service', () => ({ markTableJobRunning: mockMarkTableJobRunning })) +vi.mock('@/lib/table/export-runner', () => ({ runTableExport: mockRunTableExport })) +vi.mock('@/lib/core/utils/background', () => ({ + runDetached: (_label: string, work: () => Promise) => { + void work() + }, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { POST } from '@/app/api/table/[tableId]/export-async/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [{ name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 50000, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(body: unknown, tableId = 'tbl_1') { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export-async`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + return POST(req, { params: Promise.resolve({ tableId }) }) +} + +const validBody = { workspaceId: 'workspace-1', format: 'csv' } + +describe('POST /api/table/[tableId]/export-async', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockMarkTableJobRunning.mockResolvedValue(true) + mockRunTableExport.mockResolvedValue(undefined) + }) + + it('claims an export job and kicks off the worker', async () => { + const response = await makeRequest(validBody) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ tableId: 'tbl_1', jobId: 'job-id-xyz' }) + expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', 'job-id-xyz', 'export', { + format: 'csv', + }) + expect(mockRunTableExport).toHaveBeenCalledWith({ + jobId: 'job-id-xyz', + tableId: 'tbl_1', + workspaceId: 'workspace-1', + format: 'csv', + }) + }) + + it('defaults the format to csv', async () => { + const response = await makeRequest({ workspaceId: 'workspace-1' }) + expect(response.status).toBe(200) + expect(mockRunTableExport).toHaveBeenCalledWith(expect.objectContaining({ format: 'csv' })) + }) + + it('returns 409 when the claim fails', async () => { + mockMarkTableJobRunning.mockResolvedValue(false) + const response = await makeRequest(validBody) + expect(response.status).toBe(409) + expect(mockRunTableExport).not.toHaveBeenCalled() + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validBody) + expect(response.status).toBe(401) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) + + it('returns the access error status when access is denied', async () => { + mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + const response = await makeRequest(validBody) + expect(response.status).toBe(403) + expect(mockRunTableExport).not.toHaveBeenCalled() + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + expect(mockMarkTableJobRunning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts new file mode 100644 index 00000000000..26ded9b6e1d --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export-async/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { exportTableAsyncContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableExportAsync') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * POST /api/table/[tableId]/export-async + * + * Kicks off a background export for large tables (small ones stream synchronously via `/export`). + * Export jobs are read-only, so they bypass the one-running-job-per-table gate (the partial-unique + * index excludes `type = 'export'`) — an export can run alongside an import or delete, and the + * delete-mask keeps a mid-delete export consistent with the delete's outcome. + */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(exportTableAsyncContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, format } = parsed.data.body + + const access = await checkAccess(tableId, authResult.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + if (access.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const jobId = generateId() + const jobPayload: TableExportJobPayload = { format } + const claimed = await markTableJobRunning(tableId, jobId, 'export', jobPayload) + if (!claimed) { + // Only possible against another running *export*-typed insert race losing on the pkey, or a + // missing table — the active-job index excludes exports. + return NextResponse.json({ error: 'Failed to start export' }, { status: 409 }) + } + + const payload: TableExportPayload = { jobId, tableId, workspaceId, format } + if (isTriggerDevEnabled) { + try { + const [{ tableExportTask }, { tasks }] = await Promise.all([ + import('@/background/table-export'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-export', payload, { + tags: [`tableId:${tableId}`, `jobId:${jobId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, jobId).catch(() => {}) + throw error + } + } else { + runDetached('table-export', () => runTableExport(payload)) + } + + logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) + return NextResponse.json({ success: true, data: { tableId, jobId } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts new file mode 100644 index 00000000000..c3458093e68 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { hybridAuthMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table' + +const { mockCheckAccess, mockGetTableJob, mockGeneratePresignedDownloadUrl } = vi.hoisted(() => ({ + mockCheckAccess: vi.fn(), + mockGetTableJob: vi.fn(), + mockGeneratePresignedDownloadUrl: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ getTableJob: mockGetTableJob })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, +})) +vi.mock('@/app/api/table/utils', async () => { + const { NextResponse } = await import('next/server') + return { + checkAccess: mockCheckAccess, + accessError: (result: { status: number }) => + NextResponse.json({ error: 'denied' }, { status: result.status }), + } +}) + +import { GET } from '@/app/api/table/[tableId]/export/download/route' + +function buildTable(overrides: Partial = {}): TableDefinition { + return { + id: 'tbl_1', + name: 'People', + description: null, + schema: { columns: [] }, + metadata: null, + rowCount: 0, + maxRows: 1_000_000, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +function makeRequest(query: Record, tableId = 'tbl_1') { + const qs = new URLSearchParams(query).toString() + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export/download?${qs}`) + return GET(req, { params: Promise.resolve({ tableId }) }) +} + +const validQuery = { workspaceId: 'workspace-1', jobId: 'job_1' } + +describe('GET /api/table/[tableId]/export/download', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'ready', + payload: { format: 'csv', resultKey: 'workspace/workspace-1/exports/tbl_1/job_1/people.csv' }, + }) + mockGeneratePresignedDownloadUrl.mockResolvedValue('https://storage.example/signed-url') + }) + + it('resolves a ready export to a presigned URL', async () => { + const response = await makeRequest(validQuery) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toEqual({ url: 'https://storage.example/signed-url', fileName: 'people.csv' }) + expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( + 'workspace/workspace-1/exports/tbl_1/job_1/people.csv', + 'workspace' + ) + }) + + it('404s when the job is missing or not an export', async () => { + mockGetTableJob.mockResolvedValue({ id: 'job_1', type: 'delete', status: 'ready', payload: {} }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(404) + }) + + it('409s when the export is not ready yet', async () => { + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'running', + payload: { format: 'csv' }, + }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(409) + }) + + it('410s when the result file is gone from the payload', async () => { + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'export', + status: 'ready', + payload: { format: 'csv' }, + }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(410) + }) + + it('returns 401 when unauthenticated', async () => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) + const response = await makeRequest(validQuery) + expect(response.status).toBe(401) + }) + + it('returns 400 on workspace mismatch', async () => { + const response = await makeRequest({ ...validQuery, workspaceId: 'other-ws' }) + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts new file mode 100644 index 00000000000..577c2747b8c --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/export/download/route.ts @@ -0,0 +1,64 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { exportDownloadContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getTableJob } from '@/lib/table/service' +import type { TableExportJobPayload } from '@/lib/table/types' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableExportDownload') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** + * GET /api/table/[tableId]/export/download?jobId=… + * + * Resolves a completed export job to a short-lived presigned URL for the generated file. The job + * must belong to the table, be an export, and be `ready` — the worker stamps `resultKey` onto the + * job payload when the upload lands. + */ +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(exportDownloadContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, jobId } = parsed.data.query + + const access = await checkAccess(tableId, authResult.userId, 'read') + if (!access.ok) return accessError(access, requestId, tableId) + if (access.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const job = await getTableJob(tableId, jobId) + if (!job || job.type !== 'export') { + return NextResponse.json({ error: 'Export job not found' }, { status: 404 }) + } + if (job.status !== 'ready') { + return NextResponse.json({ error: 'Export is not ready' }, { status: 409 }) + } + const payload = job.payload as TableExportJobPayload | null + if (!payload?.resultKey) { + return NextResponse.json({ error: 'Export file is no longer available' }, { status: 410 }) + } + + const url = await generatePresignedDownloadUrl(payload.resultKey, 'workspace') + const fileName = payload.resultKey.split('/').pop() ?? `export.${payload.format}` + logger.info(`[${requestId}] Export download URL issued`, { tableId, jobId }) + return NextResponse.json({ success: true, data: { url, fileName } }) +}) diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts index 18fa93aca80..7ed47fa66e3 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts @@ -16,7 +16,7 @@ vi.mock('@sim/utils/id', () => ({ generateId: vi.fn().mockReturnValue('import-id-xyz'), generateShortId: vi.fn().mockReturnValue('short-id'), })) -vi.mock('@/lib/table/service', () => ({ markTableImporting: mockMarkTableImporting })) +vi.mock('@/lib/table/service', () => ({ markTableJobRunning: mockMarkTableImporting })) vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) vi.mock('@/lib/core/utils/background', () => ({ runDetached: (_label: string, work: () => Promise) => { @@ -92,7 +92,7 @@ describe('POST /api/table/[tableId]/import-async', () => { expect(response.status).toBe(200) expect(data.data).toEqual({ tableId: 'tbl_1', importId: 'import-id-xyz' }) - expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz', 'import') expect(mockRunTableImport).toHaveBeenCalledWith( expect.objectContaining({ tableId: 'tbl_1', diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts index 46190cbfb06..f256bf5f35a 100644 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts @@ -4,11 +4,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { importIntoTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { runTableImport } from '@/lib/table/import-runner' -import { markTableImporting } from '@/lib/table/service' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { markTableJobRunning, releaseJobClaim } from '@/lib/table/service' import { accessError, checkAccess } from '@/app/api/table/utils' const logger = createLogger('TableImportIntoAsync') @@ -56,31 +57,48 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } const delimiter = ext === 'tsv' ? '\t' : ',' - // Atomically claim the table — the single concurrency gate. If another import already holds it, - // this returns false (no overlapping workers writing colliding row positions). + // Atomically claim the table's job slot — the single concurrency gate. If another job (import + // or delete) already holds it, this returns false (no overlapping workers). const importId = generateId() - const claimed = await markTableImporting(tableId, importId) + const claimed = await markTableJobRunning(tableId, importId, 'import') if (!claimed) { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } - runDetached('table-import', () => - runTableImport({ - importId, - tableId, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode, - mapping, - createColumns, - }) - ) + const importPayload: TableImportPayload = { + importId, + tableId, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode, + mapping, + createColumns, + } + if (isTriggerDevEnabled) { + // Trigger.dev runs the import outside the web container, so it survives app deploys. + try { + const [{ tableImportTask }, { tasks }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-import', importPayload, { + tags: [`tableId:${tableId}`, `jobId:${importId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot until the stale-job janitor fires. + await releaseJobClaim(tableId, importId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(importPayload)) + } logger.info(`[${requestId}] Async CSV import into existing table started`, { tableId, diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts index ac3e1221924..76650baf4c1 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts @@ -55,8 +55,8 @@ vi.mock('@/lib/table/service', () => ({ importAppendRows: mockImportAppendRows, importReplaceRows: mockImportReplaceRows, dispatchAfterBatchInsert: mockDispatchAfterBatchInsert, - markTableImporting: mockMarkTableImporting, - releaseImportClaim: mockReleaseImportClaim, + markTableJobRunning: mockMarkTableImporting, + releaseJobClaim: mockReleaseImportClaim, })) import { POST } from '@/app/api/table/[tableId]/import/route' @@ -184,7 +184,7 @@ describe('POST /api/table/[tableId]/import', () => { it('releases the import claim after a successful write', async () => { const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30'))) expect(response.status).toBe(200) - expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') + expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d', 'import') expect(mockReleaseImportClaim).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d') }) diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index f04827d1ab1..ef57b09aced 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -28,8 +28,8 @@ import { importAppendRows, importReplaceRows, inferColumnType, - markTableImporting, - releaseImportClaim, + markTableJobRunning, + releaseJobClaim, sanitizeName, type TableDefinition, type TableSchema, @@ -128,11 +128,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro if (table.archivedAt) { return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) } - // Don't run a sync import on top of an in-flight background import — concurrent writers + // Don't run a sync import on top of an in-flight background job — concurrent writers // would insert at colliding row positions. - if (table.importStatus === 'importing') { + if (table.jobStatus === 'running') { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } @@ -253,12 +253,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot // taken before the parse/validation; a background import could claim the table in that window. - // markTableImporting is the single atomic gate (same one the async kickoff uses) — released in + // markTableJobRunning is the single atomic gate (same one the async kickoff uses) — released in // the finally so a sync import can't write concurrently with a background one (corrupts replace). const syncImportId = generateId() - if (!(await markTableImporting(tableId, syncImportId))) { + if (!(await markTableJobRunning(tableId, syncImportId, 'import'))) { return NextResponse.json( - { error: 'An import is already in progress for this table' }, + { error: 'A job is already in progress for this table' }, { status: 409 } ) } @@ -399,6 +399,6 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro } finally { fileStream?.destroy() // Release before the response returns, so a client refetch never observes the transient claim. - if (claimedImportId) await releaseImportClaim(tableId, claimedImportId).catch(() => {}) + if (claimedImportId) await releaseJobClaim(tableId, claimedImportId).catch(() => {}) } }) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts similarity index 61% rename from apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts rename to apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts index d45baae77e2..f1837b42dc7 100644 --- a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts @@ -6,13 +6,19 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockCheckAccess, mockMarkImportCanceled, mockAppendTableEvent } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockMarkImportCanceled: vi.fn(), - mockAppendTableEvent: vi.fn(), -})) +const { mockCheckAccess, mockMarkJobCanceled, mockGetTableJob, mockAppendTableEvent } = vi.hoisted( + () => ({ + mockCheckAccess: vi.fn(), + mockMarkJobCanceled: vi.fn(), + mockGetTableJob: vi.fn(), + mockAppendTableEvent: vi.fn(), + }) +) -vi.mock('@/lib/table/service', () => ({ markImportCanceled: mockMarkImportCanceled })) +vi.mock('@/lib/table/service', () => ({ + markJobCanceled: mockMarkJobCanceled, + getTableJob: mockGetTableJob, +})) vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) vi.mock('@/app/api/table/utils', async () => { const { NextResponse } = await import('next/server') @@ -23,14 +29,14 @@ vi.mock('@/app/api/table/utils', async () => { } }) -import { POST } from '@/app/api/table/[tableId]/import/cancel/route' +import { POST } from '@/app/api/table/[tableId]/job/cancel/route' function buildTable(overrides: Partial = {}): TableDefinition { return { id: 'tbl_1', name: 'People', description: null, - schema: { columns: [{ name: 'name', type: 'string' }] }, + schema: { columns: [] }, metadata: null, rowCount: 0, maxRows: 1_000_000, @@ -44,7 +50,7 @@ function buildTable(overrides: Partial = {}): TableDefinition { } function makeRequest(body: unknown, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import/cancel`, { + const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/job/cancel`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -52,9 +58,9 @@ function makeRequest(body: unknown, tableId = 'tbl_1') { return POST(req, { params: Promise.resolve({ tableId }) }) } -const validBody = { workspaceId: 'workspace-1', importId: 'import-id-xyz' } +const validBody = { workspaceId: 'workspace-1', jobId: 'job_1' } -describe('POST /api/table/[tableId]/import/cancel', () => { +describe('POST /api/table/[tableId]/job/cancel', () => { beforeEach(() => { vi.clearAllMocks() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ @@ -63,27 +69,31 @@ describe('POST /api/table/[tableId]/import/cancel', () => { authType: 'session', }) mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - mockMarkImportCanceled.mockResolvedValue(true) + mockMarkJobCanceled.mockResolvedValue(true) + mockGetTableJob.mockResolvedValue({ + id: 'job_1', + type: 'delete', + status: 'running', + payload: null, + }) }) - it('cancels the import and emits a canceled event', async () => { + it('cancels the job and emits a typed cancel event', async () => { const response = await makeRequest(validBody) const data = await response.json() expect(response.status).toBe(200) expect(data.data).toEqual({ canceled: true }) - expect(mockMarkImportCanceled).toHaveBeenCalledWith('tbl_1', 'import-id-xyz') + expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'import', status: 'canceled', importId: 'import-id-xyz' }) + expect.objectContaining({ kind: 'job', type: 'delete', status: 'canceled', jobId: 'job_1' }) ) }) - it('does not emit an event when nothing was importing', async () => { - mockMarkImportCanceled.mockResolvedValue(false) + it('does not emit an event when nothing was running', async () => { + mockMarkJobCanceled.mockResolvedValue(false) const response = await makeRequest(validBody) const data = await response.json() - - expect(response.status).toBe(200) expect(data.data).toEqual({ canceled: false }) expect(mockAppendTableEvent).not.toHaveBeenCalled() }) @@ -92,19 +102,12 @@ describe('POST /api/table/[tableId]/import/cancel', () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) const response = await makeRequest(validBody) expect(response.status).toBe(401) - expect(mockMarkImportCanceled).not.toHaveBeenCalled() - }) - - it('returns the access error status when access is denied', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const response = await makeRequest(validBody) - expect(response.status).toBe(403) + expect(mockMarkJobCanceled).not.toHaveBeenCalled() }) it('returns 400 on workspace mismatch', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ workspaceId: 'other-ws' }) }) - const response = await makeRequest(validBody) + const response = await makeRequest({ ...validBody, workspaceId: 'other' }) expect(response.status).toBe(400) - expect(mockMarkImportCanceled).not.toHaveBeenCalled() + expect(mockMarkJobCanceled).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts similarity index 55% rename from apps/sim/app/api/table/[tableId]/import/cancel/route.ts rename to apps/sim/app/api/table/[tableId]/job/cancel/route.ts index 62ab7310f47..b4ee3d98346 100644 --- a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts +++ b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts @@ -1,15 +1,16 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { cancelTableImportContract } from '@/lib/api/contracts/tables' +import { cancelTableJobContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { appendTableEvent } from '@/lib/table/events' -import { markImportCanceled } from '@/lib/table/service' +import { getTableJob, markJobCanceled } from '@/lib/table/service' +import type { TableJobType } from '@/lib/table/types' import { accessError, checkAccess } from '@/app/api/table/utils' -const logger = createLogger('TableImportCancelAPI') +const logger = createLogger('TableJobCancelAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -19,11 +20,11 @@ interface RouteParams { } /** - * POST /api/table/[tableId]/import/cancel + * POST /api/table/[tableId]/job/cancel * - * Cancels an in-flight async CSV import. Flips the table's import status to `canceled`, which makes - * the detached worker's next ownership check fail so it stops inserting. Committed rows are left in - * place (no rollback) — the user can delete the table. No-op if the import already finished. + * Cancels an in-flight async table job (import or delete). Flips the table's job status to + * `canceled`, which makes the detached worker's next ownership check fail so it stops. Committed + * work (inserted/deleted rows) is left in place (no rollback). No-op if the job already finished. */ export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() @@ -33,10 +34,10 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const parsed = await parseRequest(cancelTableImportContract, request, { params }) + const parsed = await parseRequest(cancelTableJobContract, request, { params }) if (!parsed.success) return parsed.response const { tableId } = parsed.data.params - const { workspaceId, importId } = parsed.data.body + const { workspaceId, jobId } = parsed.data.body const access = await checkAccess(tableId, authResult.userId, 'write') if (!access.ok) return accessError(access, requestId, tableId) @@ -44,11 +45,16 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const canceled = await markImportCanceled(tableId, importId) + // Resolve the job's actual type (from its own row — the table-level derivation excludes + // exports) so the cancel event carries the right `type`. + const job = await getTableJob(tableId, jobId) + const type = (job?.type ?? 'import') as TableJobType + + const canceled = await markJobCanceled(tableId, jobId) if (canceled) { - void appendTableEvent({ kind: 'import', tableId, importId, status: 'canceled' }) + void appendTableEvent({ kind: 'job', type, tableId, jobId, status: 'canceled' }) } - logger.info(`[${requestId}] Import cancel requested`, { tableId, importId, canceled }) + logger.info(`[${requestId}] Job cancel requested`, { tableId, jobId, type, canceled }) return NextResponse.json({ success: true, data: { canceled } }) }) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index c0b018f854e..0d185a74784 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -68,10 +68,11 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab table.updatedAt instanceof Date ? table.updatedAt.toISOString() : String(table.updatedAt), - importStatus: table.importStatus ?? null, - importId: table.importId ?? null, - importError: table.importError ?? null, - importRowsProcessed: table.importRowsProcessed ?? 0, + jobStatus: table.jobStatus ?? null, + jobId: table.jobId ?? null, + jobType: table.jobType ?? null, + jobError: table.jobError ?? null, + jobRowsProcessed: table.jobRowsProcessed ?? 0, }, }, }) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 18486c370f6..b1865223f83 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -16,7 +16,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' import { deleteRow, updateRow } from '@/lib/table' import { rowWireTranslators } from '@/app/api/table/row-wire' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { + accessError, + checkAccess, + rootErrorMessage, + rowWriteErrorResponse, +} from '@/app/api/table/utils' const logger = createLogger('TableRowAPI') @@ -167,21 +172,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR }, }) } catch (error) { - const errorMessage = toError(error).message - - if (errorMessage === 'Row not found') { - return NextResponse.json({ error: errorMessage }, { status: 404 }) + if (rootErrorMessage(error) === 'Row not found') { + return NextResponse.json({ error: 'Row not found' }, { status: 404 }) } - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 31708805ad2..372cd758041 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { type BatchInsertTableRowsBodyInput, @@ -14,7 +13,7 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation import { type AuthTypeValue, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' +import type { Filter, RowData, Sort, TableRowsCursor, TableSchema } from '@/lib/table' import { batchInsertRows, batchUpdateRows, @@ -29,7 +28,7 @@ import { import { queryRows } from '@/lib/table/service' import { TableQueryValidationError } from '@/lib/table/sql' import { rowWireTranslators } from '@/app/api/table/row-wire' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableRowsAPI') @@ -98,18 +97,8 @@ async function handleBatchInsert( }, }) } catch (error) { - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') || - errorMessage.match(/^Row \d+:/) - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch inserting rows:`, error) return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 }) @@ -197,17 +186,8 @@ export const POST = withRouteHandler( return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error inserting row:`, error) return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) @@ -231,12 +211,14 @@ export const GET = withRouteHandler( const workspaceId = searchParams.get('workspaceId') const filterParam = searchParams.get('filter') const sortParam = searchParams.get('sort') + const afterParam = searchParams.get('after') const limit = searchParams.get('limit') const offset = searchParams.get('offset') const includeTotalParam = searchParams.get('includeTotal') let filter: Record | undefined let sort: Sort | undefined + let after: TableRowsCursor | undefined try { if (filterParam) { @@ -245,14 +227,18 @@ export const GET = withRouteHandler( if (sortParam) { sort = JSON.parse(sortParam) as Sort } + if (afterParam) { + after = JSON.parse(afterParam) as TableRowsCursor + } } catch { - return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) + return NextResponse.json({ error: 'Invalid filter, sort, or after JSON' }, { status: 400 }) } const validated = tableRowsQuerySchema.parse({ workspaceId, filter, sort, + after, limit, offset, includeTotal: includeTotalParam, @@ -278,6 +264,7 @@ export const GET = withRouteHandler( sort: validated.sort ? wire.sortIn(validated.sort) : undefined, limit: validated.limit, offset: validated.offset, + after: validated.after, includeTotal: validated.includeTotal, }, requestId @@ -403,18 +390,8 @@ export const PUT = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating rows by filter:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) @@ -506,11 +483,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error deleting rows:`, error) return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) @@ -575,22 +549,8 @@ export const PATCH = withRouteHandler( return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be valid') || - errorMessage.includes('must be string') || - errorMessage.includes('must be number') || - errorMessage.includes('must be boolean') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Rows not found') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch updating rows:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index c8d9184e8c3..bc97623ef9a 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { upsertTableRowContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' @@ -10,7 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData, TableSchema } from '@/lib/table' import { upsertRow } from '@/lib/table' import { rowWireTranslators } from '@/app/api/table/row-wire' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' const logger = createLogger('TableUpsertAPI') @@ -80,19 +79,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser return validationErrorResponse(error) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error upserting row:`, error) return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts index 8ecdd2a923a..eaaf90597cc 100644 --- a/apps/sim/app/api/table/import-async/route.test.ts +++ b/apps/sim/app/api/table/import-async/route.test.ts @@ -84,7 +84,7 @@ describe('POST /api/table/import-async', () => { expect(response.status).toBe(200) expect(data.data).toEqual({ tableId: 'tbl_async', importId: 'import-id-123' }) expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ importStatus: 'importing', importId: 'import-id-123' }), + expect.objectContaining({ jobStatus: 'running', jobType: 'import', jobId: 'import-id-123' }), expect.any(String) ) expect(mockRunTableImport).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts index 239268053e7..f10b822b6e3 100644 --- a/apps/sim/app/api/table/import-async/route.ts +++ b/apps/sim/app/api/table/import-async/route.ts @@ -4,19 +4,22 @@ import { type NextRequest, NextResponse } from 'next/server' import { importTableAsyncContract } from '@/lib/api/contracts/tables' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, + deleteTable, getWorkspaceTableLimits, listTables, + releaseJobClaim, sanitizeName, TABLE_LIMITS, TableConflictError, } from '@/lib/table' -import { runTableImport } from '@/lib/table/import-runner' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableImportAsync') @@ -83,8 +86,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, maxRows: planLimits.maxRowsPerTable, maxTables: planLimits.maxTables, - importStatus: 'importing', - importId, + jobStatus: 'running', + jobType: 'import', + jobId: importId, }, requestId ) @@ -98,18 +102,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => { throw error } - runDetached('table-import', () => - runTableImport({ - importId, - tableId: table.id, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode: 'create', - }) - ) + const importPayload: TableImportPayload = { + importId, + tableId: table.id, + workspaceId, + userId, + fileKey, + fileName, + delimiter, + mode: 'create', + } + if (isTriggerDevEnabled) { + // Trigger.dev runs the import outside the web container, so it survives app deploys. + try { + const [{ tableImportTask }, { tasks }] = await Promise.all([ + import('@/background/table-import'), + import('@trigger.dev/sdk'), + ]) + await tasks.trigger('table-import', importPayload, { + tags: [`tableId:${table.id}`, `jobId:${importId}`], + }) + } catch (error) { + // A failed dispatch must not leave a ghost `running` job holding the + // table's one-write-job slot — nor, in create mode, the placeholder + // table itself: the user never saw it, so archive it back out of the + // workspace (no hard-delete surface exists; archived is invisible). + await releaseJobClaim(table.id, importId).catch(() => {}) + await deleteTable(table.id, requestId).catch(() => {}) + throw error + } + } else { + runDetached('table-import', () => runTableImport(importPayload)) + } captureServerEvent( userId, diff --git a/apps/sim/app/api/table/jobs/route.ts b/apps/sim/app/api/table/jobs/route.ts new file mode 100644 index 00000000000..912d769c39f --- /dev/null +++ b/apps/sim/app/api/table/jobs/route.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { listTableJobsContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkspaceExportJobs } from '@/lib/table/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('TableJobsAPI') + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * GET /api/table/jobs?workspaceId=…&type=export + * + * Lists a workspace's export jobs (running + recently finished) for the header tray. Exports are + * excluded from the table-level job derivation, so the tray reads them here. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const parsed = await parseRequest(listTableJobsContract, request, {}) + if (!parsed.success) return parsed.response + const { workspaceId } = parsed.data.query + + const { hasAccess } = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const jobs = await listWorkspaceExportJobs(workspaceId) + logger.info(`[${requestId}] Listed ${jobs.length} export jobs`, { workspaceId }) + return NextResponse.json({ success: true, data: { jobs } }) +}) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index ed41a7813d6..94aa8c45b4c 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -217,10 +217,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : t.archivedAt ? String(t.archivedAt) : null, - importStatus: t.importStatus ?? null, - importId: t.importId ?? null, - importError: t.importError ?? null, - importRowsProcessed: t.importRowsProcessed ?? 0, + jobStatus: t.jobStatus ?? null, + jobId: t.jobId ?? null, + jobType: t.jobType ?? null, + jobError: t.jobError ?? null, + jobRowsProcessed: t.jobRowsProcessed ?? 0, } }) diff --git a/apps/sim/app/api/table/utils.test.ts b/apps/sim/app/api/table/utils.test.ts new file mode 100644 index 00000000000..df1a05e7c73 --- /dev/null +++ b/apps/sim/app/api/table/utils.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils' + +/** Mimics drizzle's DrizzleQueryError: message is the failed SQL, real error on `cause`. */ +function wrapLikeDrizzle(cause: Error): Error { + return new Error('Failed query: insert into "user_table_rows" ...', { cause }) +} + +describe('rootErrorMessage', () => { + it('returns the message of a plain error', () => { + expect(rootErrorMessage(new Error('Schema validation failed: bad'))).toBe( + 'Schema validation failed: bad' + ) + }) + + it('unwraps the cause chain to the deepest error', () => { + const root = new Error('Maximum row limit (10000) reached for table tbl_abc') + expect(rootErrorMessage(wrapLikeDrizzle(root))).toBe(root.message) + }) + + it('stringifies non-Error values', () => { + expect(rootErrorMessage('boom')).toBe('boom') + }) +}) + +describe('rowWriteErrorResponse', () => { + it('rewrites the DB row-limit trigger error into a friendly 400', async () => { + const error = wrapLikeDrizzle( + new Error('Maximum row limit (10000) reached for table tbl_2b15ec29647040e7b8eb5d2949f556cf') + ) + const response = rowWriteErrorResponse(error) + expect(response?.status).toBe(400) + const body = await response?.json() + expect(body.error).toBe('Row limit exceeded — this table is capped at 10,000 rows') + }) + + it('passes known validation messages through as 400', async () => { + const response = rowWriteErrorResponse(new Error('Value for column "email" must be unique')) + expect(response?.status).toBe(400) + const body = await response?.json() + expect(body.error).toBe('Value for column "email" must be unique') + }) + + it('matches per-row batch validation messages', () => { + expect(rowWriteErrorResponse(new Error('Row 3: name is required'))?.status).toBe(400) + }) + + it('returns null for unknown errors so callers keep their generic 500', () => { + expect(rowWriteErrorResponse(new Error('connection refused'))).toBeNull() + expect(rowWriteErrorResponse(wrapLikeDrizzle(new Error('deadlock detected')))).toBeNull() + }) +}) diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 41a66e85bb3..c8dde913132 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { NextResponse } from 'next/server' import { createTableColumnBodySchema, @@ -6,12 +7,97 @@ import { updateTableColumnBodySchema, } from '@/lib/api/contracts/tables' import type { MultipartError } from '@/lib/core/utils/multipart' -import type { ColumnDefinition, TableDefinition } from '@/lib/table' -import { getTableById } from '@/lib/table' +import type { ColumnDefinition, Filter, TableDefinition } from '@/lib/table' +import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table' +import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +/** + * Validates a `filter` against the table's column schema, returning a 400 response on a bad field + * (or `null` when the filter is valid or absent). Shared by the routes that accept a filter + * (`delete-async`, `columns/run`) so a bad field fails fast with a clear message. + */ +export function tableFilterError( + filter: Filter | undefined, + columns: ColumnDefinition[] +): NextResponse | null { + if (!filter) return null + try { + buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, columns) + return null + } catch (error) { + if (error instanceof TableQueryValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } +} + const logger = createLogger('TableUtils') +/** + * Deepest `Error` message in the cause chain. Drizzle wraps DB errors (e.g. the + * row-limit trigger's RAISE) in a `DrizzleQueryError` whose own message is just + * the failed SQL — substring classification must look at the root cause. + */ +export function rootErrorMessage(error: unknown): string { + let current: unknown = error + while (current instanceof Error && current.cause instanceof Error) { + current = current.cause + } + return toError(current).message +} + +/** + * Known user-facing row-write failures (service validation + the DB row-limit + * trigger). Anything outside this list stays a generic 500 — unknown errors can + * carry SQL/internals that don't belong in a toast. + */ +const ROW_WRITE_ERROR_PATTERNS = [ + 'row limit', + 'Insufficient capacity', + 'Schema validation', + 'must be unique', + 'must be valid', + 'must be string', + 'must be number', + 'must be boolean', + 'unique column', + 'Unique constraint violation', + 'Row size exceeds', + 'conflictTarget', + 'Upsert requires', + 'Rows not found', + 'Filter is required', +] as const + +/** + * Maps a known user-facing row-write failure to a 400 carrying the real message + * (so client toasts can show the actual reason); `null` when the error is + * unrecognized and the caller should log it and return its generic 500. + */ +export function rowWriteErrorResponse(error: unknown): NextResponse | null { + const message = rootErrorMessage(error) + + // Trigger message reads `Maximum row limit (N) reached for table tbl_...` — + // rewrite it for the toast instead of leaking the internal table id. + const limitMatch = message.match(/Maximum row limit \((\d+)\) reached/) + if (limitMatch) { + return NextResponse.json( + { + error: `Row limit exceeded — this table is capped at ${Number(limitMatch[1]).toLocaleString('en-US')} rows`, + }, + { status: 400 } + ) + } + + if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) { + return NextResponse.json({ error: message }, { status: 400 }) + } + + return null +} + /** * Next.js buffers the request body for the proxy and silently truncates it past this * size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index ecceb41b1e2..bce536fc9fb 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { type V1BatchInsertTableRowsBody, @@ -34,7 +33,7 @@ import { } from '@/lib/table' import { queryRows } from '@/lib/table/service' import { TableQueryValidationError } from '@/lib/table/sql' -import { accessError, checkAccess } from '@/app/api/table/utils' +import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils' import { checkRateLimit, checkWorkspaceScope, @@ -104,18 +103,8 @@ async function handleBatchInsert( }, }) } catch (error) { - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') || - errorMessage.match(/^Row \d+:/) - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error batch inserting rows:`, error) return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 }) @@ -287,17 +276,8 @@ export const POST = withRouteHandler( const validationResponse = validationErrorResponseFromError(error) if (validationResponse) return validationResponse - const errorMessage = toError(error).message - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error inserting row:`, error) return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) @@ -381,18 +361,8 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error updating rows by filter:`, error) return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) @@ -478,11 +448,8 @@ export const DELETE = withRouteHandler( return NextResponse.json({ error: error.message }, { status: 400 }) } - const errorMessage = toError(error).message - - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const response = rowWriteErrorResponse(error) + if (response) return response logger.error(`[${requestId}] Error deleting rows:`, error) return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index 12aa2fdf5c8..e433cfa5a6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -1,13 +1,24 @@ 'use client' -import type React from 'react' import { useState } from 'react' import { toError } from '@sim/utils/errors' -import { X } from 'lucide-react' -import { Button, ChipCombobox, FieldDivider, Input, Label, Switch, toast } from '@/components/emcn' +import { + Button, + ChipCombobox, + ChipInput, + FieldDivider, + Label, + Switch, + toast, +} from '@/components/emcn' +import { X } from '@/components/emcn/icons' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition } from '@/lib/table' +import { + FieldError, + RequiredLabel, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' @@ -169,7 +180,7 @@ function ColumnConfigBody({
Column name - { @@ -178,6 +189,7 @@ function ColumnConfigBody({ }} spellCheck={false} autoComplete='off' + error={Boolean((showValidation && !trimmedName) || nameError)} aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} /> {showValidation && !trimmedName && } @@ -228,16 +240,3 @@ function ColumnConfigBody({
) } - -function RequiredLabel({ htmlFor, children }: { htmlFor?: string; children: React.ReactNode }) { - return ( - - ) -} - -function FieldError({ message }: { message: string }) { - return

{message}

-} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index c6c069d6389..de00999f35d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -73,20 +73,21 @@ export function ContextMenu({ disableInsert = false, disableDelete = false, }: ContextMenuProps) { - const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' + const count = selectedRowCount.toLocaleString() + const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row' const runLabel = workflowCellScoped ? selectedRowCount > 1 - ? `Run cell on ${selectedRowCount} rows` + ? `Run cell on ${count} rows` : 'Run cell' : selectedRowCount > 1 - ? `Run empty or failed cells on ${selectedRowCount} rows` + ? `Run empty or failed cells on ${count} rows` : 'Run empty or failed cells' const refreshLabel = workflowCellScoped ? selectedRowCount > 1 - ? `Re-run cell on ${selectedRowCount} rows` + ? `Re-run cell on ${count} rows` : 'Re-run cell' : selectedRowCount > 1 - ? `Re-run all cells on ${selectedRowCount} rows` + ? `Re-run all cells on ${count} rows` : 'Re-run all cells' const stopLabel = runningInSelectionCount === 1 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx index 3c30675fa3f..2a73836215d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx @@ -7,18 +7,18 @@ import { Badge, Button, ChipCombobox, + ChipInput, CollapsibleCard, FieldDivider, - Input, Label, Switch, toast, } from '@/components/emcn' import { ArrowLeft, X } from '@/components/emcn/icons' import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables' -import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table' import { deriveOutputColumnName } from '@/lib/table/column-naming' +import { FieldError } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' import type { EnrichmentConfig as EnrichmentDef } from '@/enrichments/types' import { useAddWorkflowGroup, @@ -280,12 +280,10 @@ export function EnrichmentConfig({ onChange={(columnName: string) => setInputMappings((prev) => ({ ...prev, [input.id]: columnName })) } - error={ - showValidation && input.required && !inputMappings[input.id] - ? 'Required' - : null - } /> + {showValidation && input.required && !inputMappings[input.id] && ( + + )} ))}
@@ -317,16 +315,16 @@ export function EnrichmentConfig({ } > - setOutputNames((prev) => ({ ...prev, [output.id]: e.target.value })) } spellCheck={false} autoComplete='off' - className={cn(outErr && 'border-[var(--text-error)]')} + error={Boolean(outErr)} /> - {outErr &&

{outErr}

} + {outErr && } ) })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx index a2575b4b43f..01b016b4cef 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichments-sidebar.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Input } from '@/components/emcn' +import { Button, ChipInput } from '@/components/emcn' import { Search, X } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import type { ColumnDefinition, WorkflowGroup } from '@/lib/table' @@ -74,14 +74,15 @@ function EnrichmentsSidebarBody({

Enrichment

- +

@@ -119,28 +120,26 @@ function EnrichmentsSidebarBody({

Enrichments

- +
-
- - setQuery(e.target.value)} - placeholder='Search' - spellCheck={false} - autoComplete='off' - className='pl-7' - /> -
+ setQuery(e.target.value)} + placeholder='Search' + spellCheck={false} + autoComplete='off' + />
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index 34b5f41f5fa..02d4710b130 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -4,6 +4,7 @@ export * from './enrichments-sidebar' export * from './new-column-dropdown' export * from './row-modal' export * from './run-status-control' +export * from './sidebar-fields' export * from './table-action-bar' export * from './table-filter' export * from './table-grid' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/index.ts new file mode 100644 index 00000000000..b8175522f14 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/index.ts @@ -0,0 +1 @@ +export { FieldError, RequiredLabel } from './sidebar-fields' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx new file mode 100644 index 00000000000..d08ad20bbfc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields/sidebar-fields.tsx @@ -0,0 +1,30 @@ +'use client' + +import type React from 'react' +import { Label } from '@/components/emcn' + +/** + * Field label with a trailing required marker, matching the sidebar field + * rhythm shared by the column-config and workflow sidebars. + */ +export function RequiredLabel({ + htmlFor, + children, +}: { + htmlFor?: string + children: React.ReactNode +}) { + return ( + + ) +} + +/** + * Inline validation error rendered under a sidebar field. + */ +export function FieldError({ message }: { message: string }) { + return

{message}

+} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx index c29a140d29c..eff97d3e890 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -1,5 +1,6 @@ 'use client' +import type React from 'react' import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' import { Button, Tooltip } from '@/components/emcn' import { Eye, PlayOutline, RefreshCw, Square } from '@/components/emcn/icons' @@ -98,71 +99,35 @@ export function TableActionBar({
{showPlay && ( - - - - - {playLabel} - + + + )} {showRefresh && ( - - - - - {refreshLabel} - + + + )} {runningCount > 0 && ( - - - - - {stopLabel} - + + + )} {onViewExecution && ( - - - - - View execution - + + + )}
@@ -172,3 +137,34 @@ export function TableActionBar({ ) } + +interface ActionIconButtonProps { + /** Tooltip text, also used as the button's accessible label. */ + label: string + onClick: () => void + disabled: boolean + children: React.ReactNode +} + +/** + * Tooltip-wrapped icon button sharing the action bar's brand-hover chrome, + * so the chrome string lives in one place. + */ +function ActionIconButton({ label, onClick, disabled, children }: ActionIconButtonProps) { + return ( + + + + + {label} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx index 7b0737f6fd1..9d9800bffe3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/table-filter.tsx @@ -2,24 +2,13 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { generateShortId } from '@sim/utils/id' -import { X } from 'lucide-react' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/emcn' -import { ChevronDown, Plus } from '@/components/emcn/icons' +import { Button, ChipDropdown, ChipInput } from '@/components/emcn' +import { Plus, X } from '@/components/emcn/icons' import type { ColumnDefinition, Filter, FilterRule } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants' import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters' -const OPERATOR_LABELS = Object.fromEntries( - COMPARISON_OPERATORS.map((op) => [op.value, op.label]) -) as Record - interface TableFilterProps { columns: ColumnDefinition[] filter: Filter | null @@ -150,6 +139,14 @@ const FilterRuleRow = memo(function FilterRuleRow({ onApply, onToggleLogical, }: FilterRuleRowProps) { + // Keep a stale column id selectable/visible (e.g. after the column was + // removed) instead of falling back to the placeholder while the rule still + // filters on it. + const columnOptions = + rule.column && !columns.some((col) => col.value === rule.column) + ? [...columns, { value: rule.column, label: rule.column }] + : columns + return (
{isFirst ? ( @@ -163,67 +160,49 @@ const FilterRuleRow = memo(function FilterRuleRow({ )} - - - - - - {columns.map((col) => ( - onUpdate(rule.id, 'column', col.value)} - > - {col.label} - - ))} - - - - - - - - - {COMPARISON_OPERATORS.map((op) => ( - onUpdate(rule.id, 'operator', op.value)} - > - {op.label} - - ))} - - + onUpdate(rule.id, 'column', value)} + placeholder='Column' + align='start' + matchTriggerWidth={false} + className='min-w-[100px]' + /> + + onUpdate(rule.id, 'operator', value)} + placeholder='Operator' + align='start' + matchTriggerWidth={false} + className='min-w-[90px]' + /> {VALUELESS_OPERATORS.has(rule.operator) ? (
) : ( - onUpdate(rule.id, 'value', e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onApply() }} placeholder='Enter a value' - className='h-[30px] flex-1 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] dark:bg-[var(--surface-4)]' + className='flex-1' /> )} - +
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx index f499a41633e..d610d99bc60 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx @@ -40,7 +40,6 @@ export function ExpandedCellPopover({ const rootRef = useRef(null) const textareaRef = useRef(null) const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null) - const [draftValue, setDraftValue] = useState('') const target = useMemo(() => { if (!expandedCell) return null @@ -75,7 +74,6 @@ export function ExpandedCellPopover({ setRect(null) return } - setDraftValue(isEditable ? formatValueForInput(target.value, target.column.type) : '') const selector = `[data-table-scroll] [data-row-id="${target.row.id}"][data-col="${target.colIndex}"]` const el = document.querySelector(selector) if (!el) { @@ -86,7 +84,7 @@ export function ExpandedCellPopover({ setRect({ top: r.top, left: r.left, width: r.width }) // Focus textarea on open so typing works immediately. requestAnimationFrame(() => textareaRef.current?.focus()) - }, [expandedCell, target, isEditable]) + }, [expandedCell, target]) const onCloseEvent = useEffectEvent(onClose) @@ -136,23 +134,6 @@ export function ExpandedCellPopover({ ? Math.max(VIEWPORT_PAD, window.innerHeight - EXPANDED_CELL_HEIGHT - VIEWPORT_PAD) : rect.top - const handleSave = () => { - if (!isEditable) return - // `displayToStorage` only normalizes dates — it returns null for anything else. - // Fall back to the raw draft for non-date columns, matching the inline editor. - const raw = displayToStorage(draftValue) ?? draftValue - const cleaned = cleanCellValue(raw, target.column) - onSave(target.row.id, target.column.key, cleaned, 'blur') - onClose() - } - - const handleTextareaKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSave() - } - } - return (
{isEditable ? ( - <> -