Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions apps/sim/app/api/table/[tableId]/import/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ const {
mockDispatchAfterBatchInsert,
mockMarkTableImporting,
mockReleaseImportClaim,
mockGetMaxRowsPerTable,
} = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockImportAppendRows: vi.fn(),
mockImportReplaceRows: vi.fn(),
mockDispatchAfterBatchInsert: vi.fn(),
mockMarkTableImporting: vi.fn(),
mockReleaseImportClaim: vi.fn(),
mockGetMaxRowsPerTable: vi.fn(),
}))

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

/** The append pre-check reads the workspace's current plan row limit, not the frozen `table.maxRows`. */
vi.mock('@/lib/table/billing', () => ({
getMaxRowsPerTable: mockGetMaxRowsPerTable,
wouldExceedRowLimit: (limit: number, current: number, added: number) =>
limit >= 0 && current + added > limit,
}))

import { POST } from '@/app/api/table/[tableId]/import/route'

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

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

it('rejects append when it would exceed maxRows', async () => {
mockCheckAccess.mockResolvedValueOnce({
ok: true,
table: buildTable({ rowCount: 99, maxRows: 100 }),
})
it('rejects append when it would exceed the current plan row limit', async () => {
mockCheckAccess.mockResolvedValueOnce({ ok: true, table: buildTable({ rowCount: 99 }) })
mockGetMaxRowsPerTable.mockResolvedValueOnce(100)
const response = await callPost(
createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' })
)
Expand Down
9 changes: 6 additions & 3 deletions apps/sim/app/api/table/[tableId]/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import {
createCsvParser,
dispatchAfterBatchInsert,
generateColumnId,
getMaxRowsPerTable,
inferColumnType,
markTableJobRunning,
releaseJobClaim,
sanitizeName,
type TableDefinition,
type TableSchema,
validateMapping,
wouldExceedRowLimit,
} from '@/lib/table'
import { importAppendRows, importReplaceRows } from '@/lib/table/import-data'
import {
Expand Down Expand Up @@ -264,11 +266,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
claimedImportId = syncImportId

if (mode === 'append') {
if (prospectiveTable.rowCount + coerced.length > prospectiveTable.maxRows) {
const deficit = prospectiveTable.rowCount + coerced.length - prospectiveTable.maxRows
const maxRows = await getMaxRowsPerTable(workspaceId)
if (wouldExceedRowLimit(maxRows, prospectiveTable.rowCount, coerced.length)) {
const deficit = prospectiveTable.rowCount + coerced.length - maxRows
return NextResponse.json(
{
error: `Append would exceed table row limit (${prospectiveTable.maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`,
error: `Append would exceed table row limit (${maxRows}). Currently ${prospectiveTable.rowCount} rows, ${coerced.length} new rows, ${deficit} over.`,
},
{ status: 400 }
)
Expand Down
1 change: 0 additions & 1 deletion apps/sim/app/api/table/import-async/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
schema: { columns: [{ name: 'column_1', type: 'string' }] },
workspaceId,
userId,
maxRows: planLimits.maxRowsPerTable,
maxTables: planLimits.maxTables,
jobStatus: 'running',
jobType: 'import',
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/app/api/table/import-csv/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ vi.mock('@/app/api/table/utils', async () => {
{ error: error.message },
{ status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 }
),
rowWriteErrorResponse: (error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
return message.includes('row limit')
? NextResponse.json({ error: message }, { status: 400 })
: null
},
}
})
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
Expand Down Expand Up @@ -175,6 +181,17 @@ describe('POST /api/table/import-csv', () => {
expect(mockCreateTable).not.toHaveBeenCalled()
})

it('returns 400 with the reason when an insert exceeds the plan row limit', async () => {
mockBatchInsertRows.mockRejectedValueOnce(
new Error('This table has reached its row limit (1,000 rows) on your current plan.')
)
const response = await POST(makeRequest(uploadParts(csvWithRows(250))))
const data = await response.json()

expect(response.status).toBe(400)
expect(data.error).toMatch(/row limit/)
})

it('rolls back the created table when a batch insert fails mid-stream', async () => {
mockBatchInsertRows
.mockResolvedValueOnce(Array.from({ length: 100 }, () => ({ id: 'row' })))
Expand Down
26 changes: 18 additions & 8 deletions apps/sim/app/api/table/import-csv/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
csvProxyBodyCapResponse,
multipartErrorResponse,
normalizeColumn,
rowWriteErrorResponse,
} from '@/app/api/table/utils'

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

const insertRows = async (rows: Record<string, unknown>[], state: ImportState) => {
const insertRows = async (
rows: Record<string, unknown>[],
state: ImportState,
currentRowCount: number
) => {
if (rows.length === 0) return 0
const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn)
const result = await batchInsertRows(
{ tableId: state.table.id, rows: coerced, workspaceId, userId },
state.table,
// The created table's rowCount is frozen at 0; pass the running total so the
// per-batch capacity check sees cumulative rows, not an always-empty table.
{ ...state.table, rowCount: currentRowCount },
generateId().slice(0, 8)
)
return result.length
Expand All @@ -132,7 +139,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
schema,
workspaceId,
userId,
maxRows: planLimits.maxRowsPerTable,
Comment thread
cursor[bot] marked this conversation as resolved.
maxTables: planLimits.maxTables,
},
requestId
Expand All @@ -153,13 +159,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
sample.push(record)
if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) {
state = await buildTable(sample)
inserted += await insertRows(sample, state)
inserted += await insertRows(sample, state, inserted)
}
continue
}
batch.push(record)
if (batch.length >= CSV_MAX_BATCH_SIZE) {
inserted += await insertRows(batch, state)
inserted += await insertRows(batch, state, inserted)
batch = []
}
}
Expand All @@ -169,9 +175,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 })
}
state = await buildTable(sample)
inserted += await insertRows(sample, state)
inserted += await insertRows(sample, state, inserted)
} else {
inserted += await insertRows(batch, state)
inserted += await insertRows(batch, state, inserted)
Comment thread
cursor[bot] marked this conversation as resolved.
}
} catch (streamError) {
if (state) await deleteTable(state.table.id, requestId).catch(() => {})
Expand Down Expand Up @@ -200,9 +206,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
if (isMultipartError(error)) return multipartErrorResponse(error)

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

// Row-write failures (e.g. the plan row-limit check) map to a 400 with the real reason.
const rowWriteError = rowWriteErrorResponse(error)
if (rowWriteError) return rowWriteError

const message = toError(error).message
const isClientError =
message.includes('maximum table limit') ||
message.includes('CSV file has no') ||
Expand Down
1 change: 0 additions & 1 deletion apps/sim/app/api/table/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
schema: normalizedSchema,
workspaceId: params.workspaceId,
userId: authResult.userId,
maxRows: planLimits.maxRowsPerTable,
maxTables: planLimits.maxTables,
initialRowCount: params.initialRowCount,
},
Expand Down
14 changes: 7 additions & 7 deletions apps/sim/app/api/table/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { TableRowLimitError } from '@/lib/table/billing'
import { rootErrorMessage, rowWriteErrorResponse } from '@/app/api/table/utils'

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

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

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

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

it('passes known validation messages through as 400', async () => {
Expand Down
24 changes: 6 additions & 18 deletions apps/sim/app/api/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ export function tableFilterError(
const logger = createLogger('TableUtils')

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

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

// Trigger message reads `Maximum row limit (N) reached for table tbl_...` —
// rewrite it for the toast instead of leaking the internal table id.
const limitMatch = message.match(/Maximum row limit \((\d+)\) reached/)
if (limitMatch) {
return NextResponse.json(
{
error: `Row limit exceeded — this table is capped at ${Number(limitMatch[1]).toLocaleString('en-US')} rows`,
},
{ status: 400 }
)
}

if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) {
return NextResponse.json({ error: message }, { status: 400 })
}
Expand Down
1 change: 0 additions & 1 deletion apps/sim/app/api/v1/tables/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
schema: normalizedSchema,
workspaceId: params.workspaceId,
userId,
maxRows: planLimits.maxRowsPerTable,
maxTables: planLimits.maxTables,
},
requestId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface DataRowProps {
runningCount: number
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
hasWorkflowColumns: boolean
/** Width of the centered row-number/checkbox region in px, derived from the table's maxRows digit count. */
/** Width of the centered row-number/checkbox region in px, derived from the table's row-count digit count. */
numRegionWidth: number
onStopRow: (rowId: string) => void
onRunRow: (rowId: string) => void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ export function TableGrid({

const hasWorkflowColumns = columns.some((c) => !!c.workflowGroupId)
const { colWidth: checkboxColWidth, numRegionWidth } = checkboxColLayout(
tableData?.maxRows ?? 0,
tableData?.rowCount ?? 0,
hasWorkflowColumns
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ export function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]):
return true
}

/** Returns sticky row-number column dimensions sized to the digit count of `maxRows`. */
/** Returns sticky row-number column dimensions sized to the digit count of `rowCount`. */
export function checkboxColLayout(
maxRows: number,
rowCount: number,
hasWorkflowCols: boolean
): { colWidth: number; numRegionWidth: number } {
Comment thread
TheodoreSpeaks marked this conversation as resolved.
const digits = maxRows > 0 ? Math.floor(Math.log10(maxRows)) + 1 : 1
const digits = rowCount > 0 ? Math.floor(Math.log10(rowCount)) + 1 : 1
const numWidth = Math.max(20, digits * 8 + 4)
// Region the number/checkbox is centered within (digit width + 12px breathing
// room, min 32). The select-all header checkbox centers in the same region so it
Expand Down
Loading
Loading