Skip to content

Commit 4911e60

Browse files
fix(tables): enforce row limits against the current plan, not a frozen per-table cap
1 parent ea505f0 commit 4911e60

24 files changed

Lines changed: 16965 additions & 74 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.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
132132
schema,
133133
workspaceId,
134134
userId,
135-
maxRows: planLimits.maxRowsPerTable,
136135
maxTables: planLimits.maxTables,
137136
},
138137
requestId

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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ export function TableGrid({
619619

620620
const hasWorkflowColumns = columns.some((c) => !!c.workflowGroupId)
621621
const { colWidth: checkboxColWidth, numRegionWidth } = checkboxColLayout(
622-
tableData?.maxRows ?? 0,
622+
tableData?.rowCount ?? 0,
623623
hasWorkflowColumns
624624
)
625625

0 commit comments

Comments
 (0)