Skip to content

Commit 597d7ea

Browse files
fix(tables): enforce row limits against the current plan, not a frozen per-table cap (#5120)
* fix(tables): enforce row limits against the current plan, not a frozen per-table cap * fix(tables): gate multi-batch CSV create + initial rows against the plan, harden limits cache bound * fix(tables): thread running row count through copilot batchInsertAll capacity check * chore(tables): align tx-variant capacity docstrings * fix(tables): map row-limit errors to 400 in create-from-CSV import * feat(tables): add Upgrade action to the row-limit toast * fix(tables): keep CreateTableData.maxRows so staging callers type-check after merge * improvement(tables): route row-limit Upgrade action to the explore-plans page
1 parent badfbc3 commit 597d7ea

26 files changed

Lines changed: 17058 additions & 89 deletions

File tree

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ const {
1313
mockDispatchAfterBatchInsert,
1414
mockMarkTableImporting,
1515
mockReleaseImportClaim,
16+
mockGetMaxRowsPerTable,
1617
} = vi.hoisted(() => ({
1718
mockCheckAccess: vi.fn(),
1819
mockImportAppendRows: vi.fn(),
1920
mockImportReplaceRows: vi.fn(),
2021
mockDispatchAfterBatchInsert: vi.fn(),
2122
mockMarkTableImporting: vi.fn(),
2223
mockReleaseImportClaim: vi.fn(),
24+
mockGetMaxRowsPerTable: vi.fn(),
2325
}))
2426

2527
vi.mock('@sim/utils/id', () => ({
@@ -65,6 +67,13 @@ vi.mock('@/lib/table/rows/service', () => ({
6567
dispatchAfterBatchInsert: mockDispatchAfterBatchInsert,
6668
}))
6769

70+
/** The append pre-check reads the workspace's current plan row limit, not the frozen `table.maxRows`. */
71+
vi.mock('@/lib/table/billing', () => ({
72+
getMaxRowsPerTable: mockGetMaxRowsPerTable,
73+
wouldExceedRowLimit: (limit: number, current: number, added: number) =>
74+
limit >= 0 && current + added > limit,
75+
}))
76+
6877
import { POST } from '@/app/api/table/[tableId]/import/route'
6978

7079
function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File {
@@ -167,6 +176,7 @@ describe('POST /api/table/[tableId]/import', () => {
167176
mockImportReplaceRows.mockResolvedValue({ deletedCount: 0, insertedCount: 0 })
168177
mockMarkTableImporting.mockResolvedValue(true)
169178
mockReleaseImportClaim.mockResolvedValue(undefined)
179+
mockGetMaxRowsPerTable.mockResolvedValue(1_000_000)
170180
})
171181

172182
it('returns 401 when the user is not authenticated', async () => {
@@ -288,11 +298,9 @@ describe('POST /api/table/[tableId]/import', () => {
288298
expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
289299
})
290300

291-
it('rejects append when it would exceed maxRows', async () => {
292-
mockCheckAccess.mockResolvedValueOnce({
293-
ok: true,
294-
table: buildTable({ rowCount: 99, maxRows: 100 }),
295-
})
301+
it('rejects append when it would exceed the current plan row limit', async () => {
302+
mockCheckAccess.mockResolvedValueOnce({ ok: true, table: buildTable({ rowCount: 99 }) })
303+
mockGetMaxRowsPerTable.mockResolvedValueOnce(100)
296304
const response = await callPost(
297305
createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' })
298306
)

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ import {
2525
createCsvParser,
2626
dispatchAfterBatchInsert,
2727
generateColumnId,
28+
getMaxRowsPerTable,
2829
inferColumnType,
2930
markTableJobRunning,
3031
releaseJobClaim,
3132
sanitizeName,
3233
type TableDefinition,
3334
type TableSchema,
3435
validateMapping,
36+
wouldExceedRowLimit,
3537
} from '@/lib/table'
3638
import { importAppendRows, importReplaceRows } from '@/lib/table/import-data'
3739
import {
@@ -264,11 +266,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
264266
claimedImportId = syncImportId
265267

266268
if (mode === 'append') {
267-
if (prospectiveTable.rowCount + coerced.length > prospectiveTable.maxRows) {
268-
const deficit = prospectiveTable.rowCount + coerced.length - prospectiveTable.maxRows
269+
const maxRows = await getMaxRowsPerTable(workspaceId)
270+
if (wouldExceedRowLimit(maxRows, prospectiveTable.rowCount, coerced.length)) {
271+
const deficit = prospectiveTable.rowCount + coerced.length - maxRows
269272
return NextResponse.json(
270273
{
271-
error: `Append would exceed table row limit (${prospectiveTable.maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`,
274+
error: `Append would exceed table row limit (${maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`,
272275
},
273276
{ status: 400 }
274277
)

apps/sim/app/api/table/import-async/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8484
schema: { columns: [{ name: 'column_1', type: 'string' }] },
8585
workspaceId,
8686
userId,
87-
maxRows: planLimits.maxRowsPerTable,
8887
maxTables: planLimits.maxTables,
8988
jobStatus: 'running',
9089
jobType: 'import',

apps/sim/app/api/table/import-csv/route.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ vi.mock('@/app/api/table/utils', async () => {
3939
{ error: error.message },
4040
{ status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 }
4141
),
42+
rowWriteErrorResponse: (error: unknown) => {
43+
const message = error instanceof Error ? error.message : String(error)
44+
return message.includes('row limit')
45+
? NextResponse.json({ error: message }, { status: 400 })
46+
: null
47+
},
4248
}
4349
})
4450
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
@@ -175,6 +181,17 @@ describe('POST /api/table/import-csv', () => {
175181
expect(mockCreateTable).not.toHaveBeenCalled()
176182
})
177183

184+
it('returns 400 with the reason when an insert exceeds the plan row limit', async () => {
185+
mockBatchInsertRows.mockRejectedValueOnce(
186+
new Error('This table has reached its row limit (1,000 rows) on your current plan.')
187+
)
188+
const response = await POST(makeRequest(uploadParts(csvWithRows(250))))
189+
const data = await response.json()
190+
191+
expect(response.status).toBe(400)
192+
expect(data.error).toMatch(/row limit/)
193+
})
194+
178195
it('rolls back the created table when a batch insert fails mid-stream', async () => {
179196
mockBatchInsertRows
180197
.mockResolvedValueOnce(Array.from({ length: 100 }, () => ({ id: 'row' })))

apps/sim/app/api/table/import-csv/route.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
csvProxyBodyCapResponse,
3131
multipartErrorResponse,
3232
normalizeColumn,
33+
rowWriteErrorResponse,
3334
} from '@/app/api/table/utils'
3435

3536
const logger = createLogger('TableImportCSV')
@@ -105,12 +106,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
105106
headerToColumn: Map<string, string>
106107
}
107108

108-
const insertRows = async (rows: Record<string, unknown>[], state: ImportState) => {
109+
const insertRows = async (
110+
rows: Record<string, unknown>[],
111+
state: ImportState,
112+
currentRowCount: number
113+
) => {
109114
if (rows.length === 0) return 0
110115
const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn)
111116
const result = await batchInsertRows(
112117
{ tableId: state.table.id, rows: coerced, workspaceId, userId },
113-
state.table,
118+
// The created table's rowCount is frozen at 0; pass the running total so the
119+
// per-batch capacity check sees cumulative rows, not an always-empty table.
120+
{ ...state.table, rowCount: currentRowCount },
114121
generateId().slice(0, 8)
115122
)
116123
return result.length
@@ -132,7 +139,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
132139
schema,
133140
workspaceId,
134141
userId,
135-
maxRows: planLimits.maxRowsPerTable,
136142
maxTables: planLimits.maxTables,
137143
},
138144
requestId
@@ -153,13 +159,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
153159
sample.push(record)
154160
if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) {
155161
state = await buildTable(sample)
156-
inserted += await insertRows(sample, state)
162+
inserted += await insertRows(sample, state, inserted)
157163
}
158164
continue
159165
}
160166
batch.push(record)
161167
if (batch.length >= CSV_MAX_BATCH_SIZE) {
162-
inserted += await insertRows(batch, state)
168+
inserted += await insertRows(batch, state, inserted)
163169
batch = []
164170
}
165171
}
@@ -169,9 +175,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
169175
return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 })
170176
}
171177
state = await buildTable(sample)
172-
inserted += await insertRows(sample, state)
178+
inserted += await insertRows(sample, state, inserted)
173179
} else {
174-
inserted += await insertRows(batch, state)
180+
inserted += await insertRows(batch, state, inserted)
175181
}
176182
} catch (streamError) {
177183
if (state) await deleteTable(state.table.id, requestId).catch(() => {})
@@ -200,9 +206,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
200206
} catch (error) {
201207
if (isMultipartError(error)) return multipartErrorResponse(error)
202208

203-
const message = toError(error).message
204209
logger.error(`[${requestId}] CSV import failed:`, error)
205210

211+
// Row-write failures (e.g. the plan row-limit check) map to a 400 with the real reason.
212+
const rowWriteError = rowWriteErrorResponse(error)
213+
if (rowWriteError) return rowWriteError
214+
215+
const message = toError(error).message
206216
const isClientError =
207217
message.includes('maximum table limit') ||
208218
message.includes('CSV file has no') ||

apps/sim/app/api/table/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8282
schema: normalizedSchema,
8383
workspaceId: params.workspaceId,
8484
userId: authResult.userId,
85-
maxRows: planLimits.maxRowsPerTable,
8685
maxTables: planLimits.maxTables,
8786
initialRowCount: params.initialRowCount,
8887
},

apps/sim/app/api/table/utils.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { TableRowLimitError } from '@/lib/table/billing'
56
import { rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils'
67

78
/** Mimics drizzle's DrizzleQueryError: message is the failed SQL, real error on `cause`. */
@@ -17,7 +18,7 @@ describe('rootErrorMessage', () => {
1718
})
1819

1920
it('unwraps the cause chain to the deepest error', () => {
20-
const root = new Error('Maximum row limit (10000) reached for table tbl_abc')
21+
const root = new Error('Value for column "email" must be unique')
2122
expect(rootErrorMessage(wrapLikeDrizzle(root))).toBe(root.message)
2223
})
2324

@@ -27,14 +28,13 @@ describe('rootErrorMessage', () => {
2728
})
2829

2930
describe('rowWriteErrorResponse', () => {
30-
it('rewrites the DB row-limit trigger error into a friendly 400', async () => {
31-
const error = wrapLikeDrizzle(
32-
new Error('Maximum row limit (10000) reached for table tbl_2b15ec29647040e7b8eb5d2949f556cf')
33-
)
34-
const response = rowWriteErrorResponse(error)
31+
it('passes the plan row-limit error through as a 400', async () => {
32+
const response = rowWriteErrorResponse(new TableRowLimitError(10000))
3533
expect(response?.status).toBe(400)
3634
const body = await response?.json()
37-
expect(body.error).toBe('Row limit exceeded — this table is capped at 10,000 rows')
35+
expect(body.error).toBe(
36+
'This table has reached its row limit (10,000 rows) on your current plan.'
37+
)
3838
})
3939

4040
it('passes known validation messages through as 400', async () => {

apps/sim/app/api/table/utils.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ export function tableFilterError(
3636
const logger = createLogger('TableUtils')
3737

3838
/**
39-
* Deepest `Error` message in the cause chain. Drizzle wraps DB errors (e.g. the
40-
* row-limit trigger's RAISE) in a `DrizzleQueryError` whose own message is just
41-
* the failed SQL — substring classification must look at the root cause.
39+
* Deepest `Error` message in the cause chain. Drizzle wraps DB errors in a
40+
* `DrizzleQueryError` whose own message is just the failed SQL — substring
41+
* classification must look at the root cause.
4242
*/
4343
export function rootErrorMessage(error: unknown): string {
4444
let current: unknown = error
@@ -49,9 +49,9 @@ export function rootErrorMessage(error: unknown): string {
4949
}
5050

5151
/**
52-
* Known user-facing row-write failures (service validation + the DB row-limit
53-
* trigger). Anything outside this list stays a generic 500 — unknown errors can
54-
* carry SQL/internals that don't belong in a toast.
52+
* Known user-facing row-write failures (service validation + the best-effort
53+
* plan row-limit check). Anything outside this list stays a generic 500 —
54+
* unknown errors can carry SQL/internals that don't belong in a toast.
5555
*/
5656
const ROW_WRITE_ERROR_PATTERNS = [
5757
'row limit',
@@ -79,18 +79,6 @@ const ROW_WRITE_ERROR_PATTERNS = [
7979
export function rowWriteErrorResponse(error: unknown): NextResponse | null {
8080
const message = rootErrorMessage(error)
8181

82-
// Trigger message reads `Maximum row limit (N) reached for table tbl_...` —
83-
// rewrite it for the toast instead of leaking the internal table id.
84-
const limitMatch = message.match(/Maximum row limit \((\d+)\) reached/)
85-
if (limitMatch) {
86-
return NextResponse.json(
87-
{
88-
error: `Row limit exceeded — this table is capped at ${Number(limitMatch[1]).toLocaleString('en-US')} rows`,
89-
},
90-
{ status: 400 }
91-
)
92-
}
93-
9482
if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) {
9583
return NextResponse.json({ error: message }, { status: 400 })
9684
}

apps/sim/app/api/v1/tables/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
108108
schema: normalizedSchema,
109109
workspaceId: params.workspaceId,
110110
userId,
111-
maxRows: planLimits.maxRowsPerTable,
112111
maxTables: planLimits.maxTables,
113112
},
114113
requestId

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export interface DataRowProps {
5050
runningCount: number
5151
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
5252
hasWorkflowColumns: boolean
53-
/** Width of the centered row-number/checkbox region in px, derived from the table's maxRows digit count. */
53+
/** Width of the centered row-number/checkbox region in px, derived from the table's row-count digit count. */
5454
numRegionWidth: number
5555
onStopRow: (rowId: string) => void
5656
onRunRow: (rowId: string) => void

0 commit comments

Comments
 (0)