diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 9c562fb262..91292ef620 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -111,6 +111,7 @@ async function handleLocalFile(filename: string, userId: string): Promise { + const tag = (e.target as HTMLElement).tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + const anchor = selectionAnchorRef.current if (!anchor || editingCellRef.current || editingEmptyCellRef.current) return diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 97ff3318be..ec1cc30eb5 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -62,8 +62,8 @@ export function useWorkspaceFiles(workspaceId: string) { * Fetch file content as text via the serve URL */ async function fetchWorkspaceFileContent(key: string, signal?: AbortSignal): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace` - const response = await fetch(serveUrl, { signal }) + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` + const response = await fetch(serveUrl, { signal, cache: 'no-store' }) if (!response.ok) { throw new Error('Failed to fetch file content') diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 8bf5f93c6c..aed64e88ed 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -4,11 +4,13 @@ import type { UserTableArgs, UserTableResult } from '@/lib/copilot/tools/shared/ import { addTableColumn, batchInsertRows, + batchUpdateRows, createTable, deleteColumn, deleteColumns, deleteRow, deleteRowsByFilter, + deleteRowsByIds, deleteTable, getRowById, getTableById, @@ -518,6 +520,105 @@ export const userTableServerTool: BaseServerTool } } + case 'batch_update_rows': { + if (!args.tableId) { + return { success: false, message: 'Table ID is required' } + } + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + + const rawUpdates = (args as Record).updates as + | Array<{ rowId: string; data: Record }> + | undefined + const columnName = (args as Record).columnName as string | undefined + const valuesMap = (args as Record).values as + | Record + | undefined + + let updates: Array<{ rowId: string; data: Record }> + + if (rawUpdates && rawUpdates.length > 0) { + updates = rawUpdates + } else if (columnName && valuesMap) { + updates = Object.entries(valuesMap).map(([rowId, value]) => ({ + rowId, + data: { [columnName]: value }, + })) + } else { + return { + success: false, + message: + 'Provide either "updates" array or "columnName" + "values" map', + } + } + + if (updates.length > MAX_BATCH_SIZE) { + return { + success: false, + message: `Too many updates (${updates.length}). Maximum is ${MAX_BATCH_SIZE}.`, + } + } + + const table = await getTableById(args.tableId) + if (!table) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + + const requestId = crypto.randomUUID().slice(0, 8) + const result = await batchUpdateRows( + { + tableId: args.tableId, + updates: updates as Array<{ rowId: string; data: RowData }>, + workspaceId, + }, + table, + requestId + ) + + return { + success: true, + message: `Updated ${result.affectedCount} rows`, + data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds }, + } + } + + case 'batch_delete_rows': { + if (!args.tableId) { + return { success: false, message: 'Table ID is required' } + } + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + + const rowIds = (args as Record).rowIds as string[] | undefined + if (!rowIds || rowIds.length === 0) { + return { success: false, message: 'rowIds array is required' } + } + + if (rowIds.length > MAX_BATCH_SIZE) { + return { + success: false, + message: `Too many row IDs (${rowIds.length}). Maximum is ${MAX_BATCH_SIZE}.`, + } + } + + const requestId = crypto.randomUUID().slice(0, 8) + const result = await deleteRowsByIds( + { tableId: args.tableId, rowIds, workspaceId }, + requestId + ) + + return { + success: true, + message: `Deleted ${result.deletedCount} rows`, + data: { + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + }, + } + } + case 'create_from_file': { const filePath = (args as Record).filePath as string | undefined if (!filePath) { diff --git a/apps/sim/lib/copilot/tools/shared/schemas.ts b/apps/sim/lib/copilot/tools/shared/schemas.ts index a0abf971f2..f7bdc6171a 100644 --- a/apps/sim/lib/copilot/tools/shared/schemas.ts +++ b/apps/sim/lib/copilot/tools/shared/schemas.ts @@ -121,6 +121,8 @@ export const UserTableArgsSchema = z.object({ 'delete_row', 'update_rows_by_filter', 'delete_rows_by_filter', + 'batch_update_rows', + 'batch_delete_rows', 'add_column', 'rename_column', 'delete_column', @@ -135,6 +137,11 @@ export const UserTableArgsSchema = z.object({ rowId: z.string().optional(), data: z.record(z.any()).optional(), rows: z.array(z.record(z.any())).optional(), + updates: z + .array(z.object({ rowId: z.string(), data: z.record(z.any()) })) + .optional(), + rowIds: z.array(z.string()).optional(), + values: z.record(z.any()).optional(), filter: z.any().optional(), sort: z.record(z.enum(['asc', 'desc'])).optional(), limit: z.number().optional(), diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 60aa729efc..87fb57308a 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -10,11 +10,12 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, count, eq, gt, gte, sql } from 'drizzle-orm' +import { and, count, eq, gt, gte, inArray, sql } from 'drizzle-orm' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants' import { buildFilterClause, buildSortClause } from './sql' import type { BatchInsertData, + BatchUpdateByIdData, BulkDeleteByIdsData, BulkDeleteByIdsResult, BulkDeleteData, @@ -1092,6 +1093,96 @@ export async function updateRowsByFilter( } } +/** + * Updates multiple rows with per-row data in a single transaction. + * Avoids the race condition of parallel update_row calls overwriting each other. + */ +export async function batchUpdateRows( + data: BatchUpdateByIdData, + table: TableDefinition, + requestId: string +): Promise { + if (data.updates.length === 0) { + return { affectedCount: 0, affectedRowIds: [] } + } + + const rowIds = data.updates.map((u) => u.rowId) + const existingRows = await db + .select({ id: userTableRows.id, data: userTableRows.data }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId), + inArray(userTableRows.id, rowIds) + ) + ) + + const existingMap = new Map(existingRows.map((r) => [r.id, r.data as RowData])) + + const missing = rowIds.filter((id) => !existingMap.has(id)) + if (missing.length > 0) { + throw new Error(`Rows not found: ${missing.join(', ')}`) + } + + const mergedUpdates: Array<{ rowId: string; mergedData: RowData }> = [] + for (const update of data.updates) { + const existing = existingMap.get(update.rowId)! + const merged = { ...existing, ...update.data } + + const sizeValidation = validateRowSize(merged) + if (!sizeValidation.valid) { + throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`) + } + + const schemaValidation = validateRowAgainstSchema(merged, table.schema) + if (!schemaValidation.valid) { + throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) + } + + mergedUpdates.push({ rowId: update.rowId, mergedData: merged }) + } + + const uniqueColumns = getUniqueColumns(table.schema) + if (uniqueColumns.length > 0) { + for (const { rowId, mergedData } of mergedUpdates) { + const uniqueValidation = await checkUniqueConstraintsDb( + data.tableId, + mergedData, + table.schema, + rowId + ) + if (!uniqueValidation.valid) { + throw new Error(`Row ${rowId}: ${uniqueValidation.errors.join(', ')}`) + } + } + } + + const now = new Date() + + await db.transaction(async (trx) => { + for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { + const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) + const updatePromises = batch.map(({ rowId, mergedData }) => + trx + .update(userTableRows) + .set({ data: mergedData, updatedAt: now }) + .where(eq(userTableRows.id, rowId)) + ) + await Promise.all(updatePromises) + } + }) + + logger.info( + `[${requestId}] Batch updated ${mergedUpdates.length} rows in table ${data.tableId}` + ) + + return { + affectedCount: mergedUpdates.length, + affectedRowIds: mergedUpdates.map((u) => u.rowId), + } +} + type DbTransaction = Parameters[0]>[0] /** diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index d5c2ff4f75..804c8c40a3 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -209,6 +209,12 @@ export interface BulkUpdateData { workspaceId: string } +export interface BatchUpdateByIdData { + tableId: string + updates: Array<{ rowId: string; data: RowData }> + workspaceId: string +} + export interface BulkDeleteData { tableId: string filter: Filter