Skip to content

Commit 498bd20

Browse files
fix(tables): gate multi-batch CSV create + initial rows against the plan, harden limits cache bound
1 parent 4911e60 commit 498bd20

4 files changed

Lines changed: 45 additions & 10 deletions

File tree

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
105105
headerToColumn: Map<string, string>
106106
}
107107

108-
const insertRows = async (rows: Record<string, unknown>[], state: ImportState) => {
108+
const insertRows = async (
109+
rows: Record<string, unknown>[],
110+
state: ImportState,
111+
currentRowCount: number
112+
) => {
109113
if (rows.length === 0) return 0
110114
const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn)
111115
const result = await batchInsertRows(
112116
{ tableId: state.table.id, rows: coerced, workspaceId, userId },
113-
state.table,
117+
// The created table's rowCount is frozen at 0; pass the running total so the
118+
// per-batch capacity check sees cumulative rows, not an always-empty table.
119+
{ ...state.table, rowCount: currentRowCount },
114120
generateId().slice(0, 8)
115121
)
116122
return result.length
@@ -152,13 +158,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
152158
sample.push(record)
153159
if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) {
154160
state = await buildTable(sample)
155-
inserted += await insertRows(sample, state)
161+
inserted += await insertRows(sample, state, inserted)
156162
}
157163
continue
158164
}
159165
batch.push(record)
160166
if (batch.length >= CSV_MAX_BATCH_SIZE) {
161-
inserted += await insertRows(batch, state)
167+
inserted += await insertRows(batch, state, inserted)
162168
batch = []
163169
}
164170
}
@@ -168,9 +174,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
168174
return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 })
169175
}
170176
state = await buildTable(sample)
171-
inserted += await insertRows(sample, state)
177+
inserted += await insertRows(sample, state, inserted)
172178
} else {
173-
inserted += await insertRows(batch, state)
179+
inserted += await insertRows(batch, state, inserted)
174180
}
175181
} catch (streamError) {
176182
if (state) await deleteTable(state.table.id, requestId).catch(() => {})

apps/sim/lib/table/billing.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ describe('getWorkspaceTableLimits', () => {
8080
// The fallback is never cached, so the next call re-attempts and resolves the real plan.
8181
expect(await getWorkspaceTableLimits(ws)).toEqual(LIMITS.pro)
8282
})
83+
84+
it('stays bounded under a burst of distinct all-fresh workspaces', async () => {
85+
// Far more distinct workspaces than the cap, all within one TTL window. The Map
86+
// must not grow without limit; eviction keeps it at/under the ceiling.
87+
for (let i = 0; i < 6_000; i++) {
88+
await getWorkspaceTableLimits(`burst-${i}`)
89+
}
90+
// Re-resolving an early (evicted) workspace must re-hit the billing lookup.
91+
mockGetWorkspaceBilledAccountUserId.mockClear()
92+
await getWorkspaceTableLimits('burst-0')
93+
expect(mockGetWorkspaceBilledAccountUserId).toHaveBeenCalledTimes(1)
94+
})
8395
})
8496

8597
describe('getMaxRowsPerTable', () => {

apps/sim/lib/table/billing.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,19 @@ export async function getWorkspaceTableLimits(workspaceId: string): Promise<Tabl
7474
}
7575

7676
function cacheLimits(workspaceId: string, limits: TablePlanLimits): void {
77-
// Sweep expired entries before growing past the cap so workspaces queried once
78-
// (never re-accessed, so never deleted on read) can't accumulate forever.
79-
if (limitsCache.size >= LIMITS_CACHE_MAX_ENTRIES) {
77+
// Keep the Map bounded for a new key: sweep expired entries, then (if a burst of
78+
// all-fresh entries still sits at the cap) evict oldest-inserted ones. Map iteration
79+
// is insertion order, so the first key is the oldest. Net: size never exceeds the cap.
80+
if (limitsCache.size >= LIMITS_CACHE_MAX_ENTRIES && !limitsCache.has(workspaceId)) {
8081
const now = Date.now()
8182
for (const [key, entry] of limitsCache) {
8283
if (entry.expiresAt <= now) limitsCache.delete(key)
8384
}
85+
while (limitsCache.size >= LIMITS_CACHE_MAX_ENTRIES) {
86+
const oldest = limitsCache.keys().next().value
87+
if (oldest === undefined) break
88+
limitsCache.delete(oldest)
89+
}
8490
}
8591
limitsCache.set(workspaceId, { limits, expiresAt: Date.now() + LIMITS_CACHE_TTL_MS })
8692
}

apps/sim/lib/table/service.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { generateId } from '@sim/utils/id'
1515
import { and, count, eq, isNull, sql } from 'drizzle-orm'
1616
import { generateRestoreName } from '@/lib/core/utils/restore-name'
1717
import type { DbOrTx } from '@/lib/db/types'
18+
import { assertRowCapacity } from '@/lib/table/billing'
1819
import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys'
1920
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
2021
import { EMPTY_JOB_FIELDS, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service'
@@ -281,6 +282,17 @@ export async function createTable(
281282
? { id: data.jobId, type: data.jobType ?? 'import', startedAt: now }
282283
: null
283284

285+
// Starter rows count against the plan too. Checked before the tx (the lookup is a
286+
// separate pool read) — a new table starts empty, so the footprint is just these.
287+
const initialRowCount = data.initialRowCount ?? 0
288+
if (initialRowCount > 0) {
289+
await assertRowCapacity({
290+
workspaceId: data.workspaceId,
291+
currentRowCount: 0,
292+
addedRows: initialRowCount,
293+
})
294+
}
295+
284296
// Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE
285297
// to prevent TOCTOU race on the table count limit
286298
try {
@@ -332,7 +344,6 @@ export async function createTable(
332344
})
333345
}
334346

335-
const initialRowCount = data.initialRowCount ?? 0
336347
if (initialRowCount > 0) {
337348
const orderKeys = nKeysBetween(null, null, initialRowCount)
338349
const rowsToInsert = Array.from({ length: initialRowCount }, (_, i) => ({

0 commit comments

Comments
 (0)