From 1abd7cd420ee995393b6e2a29987d8aabff9bab9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 1 Jun 2026 12:38:00 -0700 Subject: [PATCH 1/3] fix(tables): enforce plan limits in mothership user_table tool --- .../tools/server/table/user-table.test.ts | 94 ++++++++++++++++++- .../copilot/tools/server/table/user-table.ts | 23 ++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 4d223e842df..43bdbd4c64a 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -12,6 +12,9 @@ const { mockBatchInsertRows, mockReplaceTableRows, mockAddWorkflowGroup, + mockCreateTable, + mockDeleteTable, + mockGetWorkspaceTableLimits, fakeEnrichment, } = vi.hoisted(() => ({ mockResolveWorkspaceFileReference: vi.fn(), @@ -20,6 +23,9 @@ const { mockBatchInsertRows: vi.fn(), mockReplaceTableRows: vi.fn(), mockAddWorkflowGroup: vi.fn(), + mockCreateTable: vi.fn(), + mockDeleteTable: vi.fn(), + mockGetWorkspaceTableLimits: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -54,13 +60,13 @@ vi.mock('@/lib/table/service', () => ({ addWorkflowGroup: mockAddWorkflowGroup, batchInsertRows: mockBatchInsertRows, batchUpdateRows: vi.fn(), - createTable: vi.fn(), + createTable: mockCreateTable, deleteColumn: vi.fn(), deleteColumns: vi.fn(), deleteRow: vi.fn(), deleteRowsByFilter: vi.fn(), deleteRowsByIds: vi.fn(), - deleteTable: vi.fn(), + deleteTable: mockDeleteTable, getRowById: vi.fn(), getTableById: mockGetTableById, insertRow: vi.fn(), @@ -74,6 +80,10 @@ vi.mock('@/lib/table/service', () => ({ updateRowsByFilter: vi.fn(), })) +vi.mock('@/lib/table/billing', () => ({ + getWorkspaceTableLimits: mockGetWorkspaceTableLimits, +})) + import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' function buildTable(overrides: Partial = {}): TableDefinition { @@ -232,6 +242,86 @@ describe('userTableServerTool.import_file', () => { }) }) +describe('userTableServerTool.create_from_file', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveWorkspaceFileReference.mockResolvedValue({ name: 'people.csv', type: 'text/csv' }) + mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40')) + mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 }) + mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'people' })) + mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) => + data.rows.map((_, i) => ({ id: `row_${i}` })) + ) + }) + + it('stamps the workspace plan limits on the created table', async () => { + const result = await userTableServerTool.execute( + { operation: 'create_from_file', args: { fileId: 'file-1' } }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(true) + expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1') + expect(mockCreateTable).toHaveBeenCalledTimes(1) + const createArgs = mockCreateTable.mock.calls[0][0] as { maxRows: number; maxTables: number } + expect(createArgs.maxRows).toBe(1000) + expect(createArgs.maxTables).toBe(3) + }) + + it('rejects a file exceeding the plan row limit without creating a table', async () => { + mockGetWorkspaceTableLimits.mockResolvedValueOnce({ maxRowsPerTable: 1, maxTables: 3 }) + + const result = await userTableServerTool.execute( + { operation: 'create_from_file', args: { fileId: 'file-1' } }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toMatch(/exceeds this plan's limit/i) + expect(mockCreateTable).not.toHaveBeenCalled() + expect(mockDeleteTable).not.toHaveBeenCalled() + }) + + it('deletes the created table when row insertion fails', async () => { + mockBatchInsertRows.mockRejectedValueOnce(new Error('Maximum row limit (1000) reached')) + + const result = await userTableServerTool.execute( + { operation: 'create_from_file', args: { fileId: 'file-1' } }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(mockDeleteTable).toHaveBeenCalledWith('tbl_new', expect.any(String)) + }) +}) + +describe('userTableServerTool.create', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 }) + mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'People' })) + }) + + it('stamps the workspace plan limits on the created table', async () => { + const result = await userTableServerTool.execute( + { + operation: 'create', + args: { + name: 'People', + schema: { columns: [{ name: 'name', type: 'string', required: true }] }, + }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(true) + expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1') + const createArgs = mockCreateTable.mock.calls[0][0] as { maxRows: number; maxTables: number } + expect(createArgs.maxRows).toBe(1000) + expect(createArgs.maxTables).toBe(3) + }) +}) + describe('userTableServerTool.list_enrichments', () => { beforeEach(() => { vi.clearAllMocks() 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 578c4e909bc..89b9abe6cb4 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -14,6 +14,7 @@ import { type CsvHeaderMapping, CsvImportValidationError, coerceRowsForTable, + getWorkspaceTableLimits, inferSchemaFromCsv, parseCsvBuffer, sanitizeName, @@ -263,6 +264,7 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() + const planLimits = await getWorkspaceTableLimits(workspaceId) const table = await createTable( { name: args.name, @@ -270,6 +272,8 @@ export const userTableServerTool: BaseServerTool schema: args.schema, workspaceId, userId: context.userId, + maxRows: planLimits.maxRowsPerTable, + maxTables: planLimits.maxTables, }, requestId ) @@ -761,6 +765,15 @@ export const userTableServerTool: BaseServerTool const tableName = args.name || file.name.replace(/\.[^.]+$/, '') const requestId = generateId().slice(0, 8) assertNotAborted() + const planLimits = await getWorkspaceTableLimits(workspaceId) + + if (rows.length > planLimits.maxRowsPerTable) { + return { + success: false, + message: `"${file.name}" has ${rows.length.toLocaleString()} rows, which exceeds this plan's limit of ${planLimits.maxRowsPerTable.toLocaleString()} rows per table.`, + } + } + const table = await createTable( { name: tableName, @@ -768,12 +781,20 @@ export const userTableServerTool: BaseServerTool schema: { columns }, workspaceId, userId: context.userId, + maxRows: planLimits.maxRowsPerTable, + maxTables: planLimits.maxTables, }, requestId ) const coerced = coerceRowsForTable(rows, { columns }, headerToColumn) - const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) + let inserted: number + try { + inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) + } catch (insertError) { + await deleteTable(table.id, requestId).catch(() => {}) + throw insertError + } logger.info('Table created from file', { tableId: table.id, From 96b203ad22b5ca2c92d611f7073519e6fdc1034b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 1 Jun 2026 12:48:07 -0700 Subject: [PATCH 2/3] improvement(tables): truncate over-limit CSV imports to the plan cap instead of rejecting --- .../tools/server/table/user-table.test.ts | 12 ++++++++---- .../copilot/tools/server/table/user-table.ts | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 43bdbd4c64a..d77f05793d6 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -268,7 +268,8 @@ describe('userTableServerTool.create_from_file', () => { expect(createArgs.maxTables).toBe(3) }) - it('rejects a file exceeding the plan row limit without creating a table', async () => { + it('truncates to the plan row limit and reports dropped rows', async () => { + // File has 2 data rows (Alice, Bob); plan cap is 1. mockGetWorkspaceTableLimits.mockResolvedValueOnce({ maxRowsPerTable: 1, maxTables: 3 }) const result = await userTableServerTool.execute( @@ -276,9 +277,12 @@ describe('userTableServerTool.create_from_file', () => { { userId: 'user-1', workspaceId: 'workspace-1' } ) - expect(result.success).toBe(false) - expect(result.message).toMatch(/exceeds this plan's limit/i) - expect(mockCreateTable).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(mockCreateTable).toHaveBeenCalledTimes(1) + const insertCall = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] } + expect(insertCall.rows).toHaveLength(1) + expect(result.data?.rowCount).toBe(1) + expect(result.message).toMatch(/dropped 1 row/i) expect(mockDeleteTable).not.toHaveBeenCalled() }) 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 89b9abe6cb4..b2329694739 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -767,12 +767,8 @@ export const userTableServerTool: BaseServerTool assertNotAborted() const planLimits = await getWorkspaceTableLimits(workspaceId) - if (rows.length > planLimits.maxRowsPerTable) { - return { - success: false, - message: `"${file.name}" has ${rows.length.toLocaleString()} rows, which exceeds this plan's limit of ${planLimits.maxRowsPerTable.toLocaleString()} rows per table.`, - } - } + const droppedRows = Math.max(0, rows.length - planLimits.maxRowsPerTable) + const rowsToImport = droppedRows > 0 ? rows.slice(0, planLimits.maxRowsPerTable) : rows const table = await createTable( { @@ -787,7 +783,7 @@ export const userTableServerTool: BaseServerTool requestId ) - const coerced = coerceRowsForTable(rows, { columns }, headerToColumn) + const coerced = coerceRowsForTable(rowsToImport, { columns }, headerToColumn) let inserted: number try { inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) @@ -801,12 +797,19 @@ export const userTableServerTool: BaseServerTool fileName: file.name, columns: columns.length, rows: inserted, + droppedRows, userId: context.userId, }) + const createdMessage = `Created table "${table.name}" with ${columns.length} columns and ${inserted.toLocaleString()} rows from "${file.name}"` + const message = + droppedRows > 0 + ? `${createdMessage}. Dropped ${droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${planLimits.maxRowsPerTable.toLocaleString()} rows per table.` + : createdMessage + return { success: true, - message: `Created table "${table.name}" with ${columns.length} columns and ${inserted} rows from "${file.name}"`, + message, data: { tableId: table.id, tableName: table.name, From 52dd4ca019b33525cfbc6ba87d844bf62e631db1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 1 Jun 2026 12:53:13 -0700 Subject: [PATCH 3/3] fix(tables): log rollback failures and surface a clear reason on failed CSV import --- .../tools/server/table/user-table.test.ts | 7 ++++-- .../copilot/tools/server/table/user-table.ts | 24 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index d77f05793d6..8ad2e0d6b2b 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -249,6 +249,7 @@ describe('userTableServerTool.create_from_file', () => { mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40')) mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 }) mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'people' })) + mockDeleteTable.mockResolvedValue(undefined) mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) => data.rows.map((_, i) => ({ id: `row_${i}` })) ) @@ -286,8 +287,8 @@ describe('userTableServerTool.create_from_file', () => { expect(mockDeleteTable).not.toHaveBeenCalled() }) - it('deletes the created table when row insertion fails', async () => { - mockBatchInsertRows.mockRejectedValueOnce(new Error('Maximum row limit (1000) reached')) + it('rolls back the created table and reports the reason when row insertion fails', async () => { + mockBatchInsertRows.mockRejectedValueOnce(new Error('Row 2: Column "email" must be unique')) const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, @@ -296,6 +297,8 @@ describe('userTableServerTool.create_from_file', () => { expect(result.success).toBe(false) expect(mockDeleteTable).toHaveBeenCalledWith('tbl_new', expect.any(String)) + expect(result.message).toMatch(/rolled back/i) + expect(result.message).toMatch(/must be unique/i) }) }) 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 b2329694739..d20d172711a 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -788,8 +788,28 @@ export const userTableServerTool: BaseServerTool try { inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) } catch (insertError) { - await deleteTable(table.id, requestId).catch(() => {}) - throw insertError + const cleanupRequestId = generateId().slice(0, 8) + await deleteTable(table.id, cleanupRequestId).catch((cleanupError) => { + logger.error('Failed to roll back table after import failure', { + tableId: table.id, + error: toError(cleanupError).message, + }) + }) + const reason = toError(insertError).message + const cause = + insertError instanceof Error && insertError.cause + ? toError(insertError.cause).message + : undefined + logger.error('Failed to import rows into new table', { + tableId: table.id, + fileName: file.name, + error: reason, + cause, + }) + return { + success: false, + message: `Failed to import rows from "${file.name}" — the table was rolled back. ${cause ? `${reason} (${cause})` : reason}`, + } } logger.info('Table created from file', {