Skip to content

Commit 1abd7cd

Browse files
fix(tables): enforce plan limits in mothership user_table tool
1 parent c6d500d commit 1abd7cd

2 files changed

Lines changed: 114 additions & 3 deletions

File tree

apps/sim/lib/copilot/tools/server/table/user-table.test.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const {
1212
mockBatchInsertRows,
1313
mockReplaceTableRows,
1414
mockAddWorkflowGroup,
15+
mockCreateTable,
16+
mockDeleteTable,
17+
mockGetWorkspaceTableLimits,
1518
fakeEnrichment,
1619
} = vi.hoisted(() => ({
1720
mockResolveWorkspaceFileReference: vi.fn(),
@@ -20,6 +23,9 @@ const {
2023
mockBatchInsertRows: vi.fn(),
2124
mockReplaceTableRows: vi.fn(),
2225
mockAddWorkflowGroup: vi.fn(),
26+
mockCreateTable: vi.fn(),
27+
mockDeleteTable: vi.fn(),
28+
mockGetWorkspaceTableLimits: vi.fn(),
2329
fakeEnrichment: {
2430
id: 'work-email',
2531
name: 'Work Email',
@@ -54,13 +60,13 @@ vi.mock('@/lib/table/service', () => ({
5460
addWorkflowGroup: mockAddWorkflowGroup,
5561
batchInsertRows: mockBatchInsertRows,
5662
batchUpdateRows: vi.fn(),
57-
createTable: vi.fn(),
63+
createTable: mockCreateTable,
5864
deleteColumn: vi.fn(),
5965
deleteColumns: vi.fn(),
6066
deleteRow: vi.fn(),
6167
deleteRowsByFilter: vi.fn(),
6268
deleteRowsByIds: vi.fn(),
63-
deleteTable: vi.fn(),
69+
deleteTable: mockDeleteTable,
6470
getRowById: vi.fn(),
6571
getTableById: mockGetTableById,
6672
insertRow: vi.fn(),
@@ -74,6 +80,10 @@ vi.mock('@/lib/table/service', () => ({
7480
updateRowsByFilter: vi.fn(),
7581
}))
7682

83+
vi.mock('@/lib/table/billing', () => ({
84+
getWorkspaceTableLimits: mockGetWorkspaceTableLimits,
85+
}))
86+
7787
import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table'
7888

7989
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
@@ -232,6 +242,86 @@ describe('userTableServerTool.import_file', () => {
232242
})
233243
})
234244

245+
describe('userTableServerTool.create_from_file', () => {
246+
beforeEach(() => {
247+
vi.clearAllMocks()
248+
mockResolveWorkspaceFileReference.mockResolvedValue({ name: 'people.csv', type: 'text/csv' })
249+
mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40'))
250+
mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 })
251+
mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'people' }))
252+
mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) =>
253+
data.rows.map((_, i) => ({ id: `row_${i}` }))
254+
)
255+
})
256+
257+
it('stamps the workspace plan limits on the created table', async () => {
258+
const result = await userTableServerTool.execute(
259+
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
260+
{ userId: 'user-1', workspaceId: 'workspace-1' }
261+
)
262+
263+
expect(result.success).toBe(true)
264+
expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1')
265+
expect(mockCreateTable).toHaveBeenCalledTimes(1)
266+
const createArgs = mockCreateTable.mock.calls[0][0] as { maxRows: number; maxTables: number }
267+
expect(createArgs.maxRows).toBe(1000)
268+
expect(createArgs.maxTables).toBe(3)
269+
})
270+
271+
it('rejects a file exceeding the plan row limit without creating a table', async () => {
272+
mockGetWorkspaceTableLimits.mockResolvedValueOnce({ maxRowsPerTable: 1, maxTables: 3 })
273+
274+
const result = await userTableServerTool.execute(
275+
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
276+
{ userId: 'user-1', workspaceId: 'workspace-1' }
277+
)
278+
279+
expect(result.success).toBe(false)
280+
expect(result.message).toMatch(/exceeds this plan's limit/i)
281+
expect(mockCreateTable).not.toHaveBeenCalled()
282+
expect(mockDeleteTable).not.toHaveBeenCalled()
283+
})
284+
285+
it('deletes the created table when row insertion fails', async () => {
286+
mockBatchInsertRows.mockRejectedValueOnce(new Error('Maximum row limit (1000) reached'))
287+
288+
const result = await userTableServerTool.execute(
289+
{ operation: 'create_from_file', args: { fileId: 'file-1' } },
290+
{ userId: 'user-1', workspaceId: 'workspace-1' }
291+
)
292+
293+
expect(result.success).toBe(false)
294+
expect(mockDeleteTable).toHaveBeenCalledWith('tbl_new', expect.any(String))
295+
})
296+
})
297+
298+
describe('userTableServerTool.create', () => {
299+
beforeEach(() => {
300+
vi.clearAllMocks()
301+
mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 })
302+
mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'People' }))
303+
})
304+
305+
it('stamps the workspace plan limits on the created table', async () => {
306+
const result = await userTableServerTool.execute(
307+
{
308+
operation: 'create',
309+
args: {
310+
name: 'People',
311+
schema: { columns: [{ name: 'name', type: 'string', required: true }] },
312+
},
313+
},
314+
{ userId: 'user-1', workspaceId: 'workspace-1' }
315+
)
316+
317+
expect(result.success).toBe(true)
318+
expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1')
319+
const createArgs = mockCreateTable.mock.calls[0][0] as { maxRows: number; maxTables: number }
320+
expect(createArgs.maxRows).toBe(1000)
321+
expect(createArgs.maxTables).toBe(3)
322+
})
323+
})
324+
235325
describe('userTableServerTool.list_enrichments', () => {
236326
beforeEach(() => {
237327
vi.clearAllMocks()

apps/sim/lib/copilot/tools/server/table/user-table.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type CsvHeaderMapping,
1515
CsvImportValidationError,
1616
coerceRowsForTable,
17+
getWorkspaceTableLimits,
1718
inferSchemaFromCsv,
1819
parseCsvBuffer,
1920
sanitizeName,
@@ -263,13 +264,16 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
263264

264265
const requestId = generateId().slice(0, 8)
265266
assertNotAborted()
267+
const planLimits = await getWorkspaceTableLimits(workspaceId)
266268
const table = await createTable(
267269
{
268270
name: args.name,
269271
description: args.description,
270272
schema: args.schema,
271273
workspaceId,
272274
userId: context.userId,
275+
maxRows: planLimits.maxRowsPerTable,
276+
maxTables: planLimits.maxTables,
273277
},
274278
requestId
275279
)
@@ -761,19 +765,36 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
761765
const tableName = args.name || file.name.replace(/\.[^.]+$/, '')
762766
const requestId = generateId().slice(0, 8)
763767
assertNotAborted()
768+
const planLimits = await getWorkspaceTableLimits(workspaceId)
769+
770+
if (rows.length > planLimits.maxRowsPerTable) {
771+
return {
772+
success: false,
773+
message: `"${file.name}" has ${rows.length.toLocaleString()} rows, which exceeds this plan's limit of ${planLimits.maxRowsPerTable.toLocaleString()} rows per table.`,
774+
}
775+
}
776+
764777
const table = await createTable(
765778
{
766779
name: tableName,
767780
description: args.description || `Imported from ${file.name}`,
768781
schema: { columns },
769782
workspaceId,
770783
userId: context.userId,
784+
maxRows: planLimits.maxRowsPerTable,
785+
maxTables: planLimits.maxTables,
771786
},
772787
requestId
773788
)
774789

775790
const coerced = coerceRowsForTable(rows, { columns }, headerToColumn)
776-
const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context)
791+
let inserted: number
792+
try {
793+
inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context)
794+
} catch (insertError) {
795+
await deleteTable(table.id, requestId).catch(() => {})
796+
throw insertError
797+
}
777798

778799
logger.info('Table created from file', {
779800
tableId: table.id,

0 commit comments

Comments
 (0)