Skip to content

fix(tables): enforce row limits against the current plan, not a frozen per-table cap#5120

Merged
TheodoreSpeaks merged 9 commits into
stagingfrom
fix/table-limit-enforcement
Jun 18, 2026
Merged

fix(tables): enforce row limits against the current plan, not a frozen per-table cap#5120
TheodoreSpeaks merged 9 commits into
stagingfrom
fix/table-limit-enforcement

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

  • Replace the frozen per-table `max_rows` enforcement (snapshotted at table creation) with a best-effort check against the workspace's current plan limit, so plan upgrades/downgrades apply to existing tables
  • Migration `0241` drops the cap guard from the statement-level row-count trigger (it still maintains `row_count`); enforcement now lives in `assertRowCapacity` across every insert path (single/batch/upsert/replace + sync & async CSV import)
  • Plan-limit lookups are resolved outside transactions and cached (30s TTL, bounded) to keep the insert hot path off the billing tables
  • Row-number gutter now sizes to the live row count; creation paths no longer freeze the plan into the column (column removal deferred to a follow-up PR for rolling-deploy safety)

Type of Change

  • Bug fix

Testing

  • `bunx vitest run lib/table app/api/table lib/copilot/tools/server/table` — 359 passing
  • `bun run check:migrations origin/staging` — backward-compatible
  • `bun run lint` clean; `bun run check:api-validation:strict` passed; `tsc --noEmit` clean

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 18, 2026 2:26am

Request Review

@cursor

cursor Bot commented Jun 17, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches core table write paths, billing lookups on the insert hot path, and a DB trigger change; enforcement is best-effort/non-transactional so small concurrent overshoot is possible, but behavior is well-tested.

Overview
Row limits now follow the workspace’s current billing plan instead of the per-table max_rows value frozen at creation. Migration 0241 removes the insert trigger’s cap guard (the trigger still updates row_count); enforcement moves to assertRowCapacity / getMaxRowsPerTable with a cached plan lookup (30s TTL, bounded map).

Every write path is wired to the new check: single/batch insert, upsert (insert branch), replace, table creation with starter rows, sync append import (pre-check via plan limit), async import runner (per batch), and import append/replace helpers. Plan resolution runs outside transactions; tx-bound insert helpers no longer enforce caps internally. Table-creation and CSV import routes stop passing maxRows from the plan into createTable (the column remains vestigial for now).

Import and API UX fixes: streaming sync CSV import passes a running rowCount into each batch so multi-batch imports can’t bypass the cap; copilot multi-batch insert does the same. rowWriteErrorResponse no longer rewrites DB trigger messages—it surfaces TableRowLimitError and other known validation strings as 400s. Row-create/batch mutations show an Upgrade toast on row-limit failures.

UI: the row-number gutter width is based on rowCount instead of maxRows.

Reviewed by Cursor Bugbot for commit c2a1b30. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread apps/sim/app/api/table/import-csv/route.ts
Comment thread apps/sim/lib/table/service.ts Outdated
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the per-table frozen max_rows cap (snapshotted at creation into the DB trigger) with a best-effort application-layer check against the workspace's current billing plan, so plan upgrades and downgrades take effect immediately on existing tables.

  • Migration 0241 strips the row-cap guard from the increment_user_table_row_count_stmt trigger, leaving it as a pure counter; enforcement moves to assertRowCapacity in billing.ts, called before every insert path (single row, batch, upsert, replace, and both sync and async CSV import).
  • Caching (billing.ts): plan-limit lookups are resolved outside transactions and cached with a 30 s TTL and a bounded 5,000-entry map; expired-entry sweep plus insertion-order eviction keep the map from growing unbounded, validated by a 6,000-workspace burst test.
  • UI: the row-number gutter now sizes to the live rowCount rather than the frozen plan limit, and failed row writes surface an "Upgrade" action toast when the plan limit is hit.

Confidence Score: 4/5

Safe to merge with one fix: a TableRowLimitError thrown by createTable when initialRowCount exceeds the plan limit is not caught as a client error in the /api/table POST route, producing a 500 instead of a 400.

The createTable service now calls assertRowCapacity for initialRowCount > 0, but the route's catch block only matches 'maximum table limit', 'Invalid table name', 'Invalid schema', and 'already exists' — none of which match TableRowLimitError's message. All other insert paths propagate the error correctly.

apps/sim/app/api/table/route.ts — the catch block needs a 'row limit' guard (or an instanceof TableRowLimitError check) to map the new error to a 400.

Important Files Changed

Filename Overview
apps/sim/lib/table/billing.ts New TableRowLimitError, wouldExceedRowLimit, assertRowCapacity, and getMaxRowsPerTable exports; adds a bounded 30s TTL cache with expired-entry sweep and LRU-style eviction so billing queries stay off the hot insert path.
apps/sim/lib/table/rows/service.ts All insert paths (single, batch, upsert, replace) now call assertRowCapacity or wouldExceedRowLimit before opening transactions; DB trigger-based enforcement comments removed.
apps/sim/app/api/table/route.ts Drops maxRows from createTable call; createTable now throws TableRowLimitError for initialRowCount overflows, but the route's catch block does not handle it and returns a 500.
apps/sim/app/api/table/import-csv/route.ts Passes a running currentRowCount to each batch so cumulative rows are checked; rowWriteErrorResponse now handles TableRowLimitError before the isClientError string matching.
apps/sim/lib/table/import-runner.ts Per-batch assertRowCapacity calls accumulate existingRowCount + inserted correctly; capacity gates outside the insert transaction.
apps/sim/lib/table/import-data.ts Both importAppendRows and importReplaceRows now gate capacity before opening the transaction; replace correctly passes currentRowCount: 0.
apps/sim/lib/table/service.ts Adds a pre-transaction assertRowCapacity for initialRowCount > 0 when creating a table; maxRows is no longer frozen into the table from billing plan.
packages/db/migrations/0241_drop_table_row_cap_guard.sql Drops the row_count < max_rows guard from the statement-level trigger; trigger now unconditionally maintains row_count only.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Row write request] --> B{Path}
    B -->|insertRow / batchInsertRows / replaceTableRows| C[assertRowCapacity before TX opens]
    B -->|upsertRow| D[getMaxRowsPerTable before TX opens]
    B -->|Sync CSV import - new table| E[batchInsertRows with running currentRowCount]
    B -->|Sync CSV import - existing table| F[importAppendRows or importReplaceRows pre-TX assertRowCapacity]
    B -->|Async CSV import| G[per-batch assertRowCapacity in streaming loop]
    C --> H{wouldExceedRowLimit?}
    D --> I{wouldExceedRowLimit inside TX}
    E --> H
    F --> H
    G --> H
    H -->|No| J[Execute insert in TX trigger maintains row_count]
    H -->|Yes| K[throw TableRowLimitError]
    I -->|Yes| K
    K --> L[rowWriteErrorResponse or message.includes row limit match in catch block]
    L --> M[400 with plan limit message + Upgrade toast on client]
    subgraph Cache
        N[getWorkspaceTableLimits]
        O[limitsCache Map 30s TTL max 5000 entries expiry sweep + LRU eviction]
        N --> O
    end
    H --> N
    I --> N
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Row write request] --> B{Path}
    B -->|insertRow / batchInsertRows / replaceTableRows| C[assertRowCapacity before TX opens]
    B -->|upsertRow| D[getMaxRowsPerTable before TX opens]
    B -->|Sync CSV import - new table| E[batchInsertRows with running currentRowCount]
    B -->|Sync CSV import - existing table| F[importAppendRows or importReplaceRows pre-TX assertRowCapacity]
    B -->|Async CSV import| G[per-batch assertRowCapacity in streaming loop]
    C --> H{wouldExceedRowLimit?}
    D --> I{wouldExceedRowLimit inside TX}
    E --> H
    F --> H
    G --> H
    H -->|No| J[Execute insert in TX trigger maintains row_count]
    H -->|Yes| K[throw TableRowLimitError]
    I -->|Yes| K
    K --> L[rowWriteErrorResponse or message.includes row limit match in catch block]
    L --> M[400 with plan limit message + Upgrade toast on client]
    subgraph Cache
        N[getWorkspaceTableLimits]
        O[limitsCache Map 30s TTL max 5000 entries expiry sweep + LRU eviction]
        N --> O
    end
    H --> N
    I --> N
Loading

Comments Outside Diff (1)

  1. apps/sim/app/api/table/route.ts, line 142-158 (link)

    P1 TableRowLimitError from createTable not handled — returns 500

    service.ts now calls assertRowCapacity when initialRowCount > 0 and throws TableRowLimitError if the count would exceed the plan limit. That error's message is "This table has reached its row limit (N rows) on your current plan." — it doesn't match 'maximum table limit', 'Invalid table name', 'Invalid schema', or 'already exists', so it falls through to the logger + 500 "Failed to create table" branch. Any request to /api/table with initialRowCount exceeding the plan limit will surface as an opaque 500 rather than an informative 400.

Reviews (8): Last reviewed commit: "improvement(tables): route row-limit Upg..." | Re-trigger Greptile

Comment thread apps/sim/lib/table/billing.ts
Comment thread apps/sim/lib/table/rows/service.ts
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the frozen per-table max_rows cap (snapshotted at creation time from the plan and stored in the DB trigger) with a live, best-effort application-layer check against the workspace's current plan limit on every insert path. Migration 0241 strips the cap guard from the increment_user_table_row_count_stmt trigger, leaving only the row-count maintenance; assertRowCapacity now covers single insert, batch insert, upsert, replace, and both sync and async CSV import flows.

  • New assertRowCapacity / wouldExceedRowLimit helpers in billing.ts resolve the plan limit outside any open transaction and throw TableRowLimitError; results are cached per workspace with a 30 s TTL (module-level Map, soft-capped at 5 000 entries) to keep the insert hot path off billing tables.
  • All creation paths (route.ts, import-async, import-csv, v1/tables, copilot tool) drop the maxRows field from CreateTableData; service.ts writes the static default instead.
  • UI gutter (checkboxColLayout) is now sized to the live rowCount rather than the static maxRows, so it correctly reflects actual table growth rather than a potentially stale plan snapshot.

Confidence Score: 4/5

Safe to merge. The enforcement shift from a frozen DB trigger to a live application check is well-scoped, consistently applied across all insert paths, and the migration is backward compatible.

The core enforcement redesign is sound and consistently applied across all insert paths. Two small gaps: the cache bound is softer than its comment implies, and the gutter-width calculation can shift mid-session as row counts cross digit boundaries.

apps/sim/lib/table/billing.ts (cache eviction comment vs. actual behaviour) and apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts (dynamic gutter sizing).

Important Files Changed

Filename Overview
apps/sim/lib/table/billing.ts Core of the change: adds a 30 s/5 000-entry cache for plan limits, exports assertRowCapacity / wouldExceedRowLimit / TableRowLimitError. Cache eviction is best-effort (soft cap, not hard) despite the comment saying otherwise.
apps/sim/lib/table/rows/service.ts Moves all capacity checks from the DB trigger to assertRowCapacity before each transaction, with upsertRow fetching the limit outside the tx and checking inside. Pattern is consistent and well-documented.
apps/sim/lib/table/import-data.ts importAppendRows and importReplaceRows both gate capacity before opening their transactions; replace correctly passes currentRowCount: 0 since all existing rows are deleted.
apps/sim/lib/table/import-runner.ts Async import batches check capacity per-batch using existingRowCount + running inserted total; baseline correctly set to 0 for replace mode and table.rowCount for append.
packages/db/migrations/0241_drop_table_row_cap_guard.sql Rewrites the trigger to only maintain row_count without the conditional cap guard. Clean migration, backward compatible, leaves the max_rows column in place for rolling-deploy safety.
apps/sim/app/api/table/utils.ts Removes the trigger-message rewriting code; TableRowLimitError messages now pass through via the existing 'row limit' pattern match. Clean simplification.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts checkboxColLayout now derives digit count from rowCount instead of maxRows, making gutter width dynamic and potentially subject to mid-session layout shifts at power-of-10 row boundaries.
apps/sim/app/api/table/[tableId]/import/route.ts Sync append check now uses getMaxRowsPerTable / wouldExceedRowLimit instead of the frozen table.maxRows. Replace path is covered by importReplaceRows.
apps/sim/lib/table/billing.test.ts Good coverage of caching, free-tier fallback, error-fallback without caching, wouldExceedRowLimit edge cases, and assertRowCapacity happy/error paths.
apps/sim/lib/table/service.ts createTable now always stores the default max_rows; the removed maxRows parameter from CreateTableData is consistently cleaned up across all call sites.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant assertRowCapacity
    participant limitsCache
    participant BillingDB
    participant InsertTx

    Caller->>assertRowCapacity: workspaceId, currentRowCount, addedRows
    assertRowCapacity->>limitsCache: get(workspaceId)
    alt cache hit (within 30s TTL)
        limitsCache-->>assertRowCapacity: cached limits
    else cache miss / expired
        assertRowCapacity->>BillingDB: getWorkspaceBilledAccountUserId + getHighestPrioritySubscription
        BillingDB-->>assertRowCapacity: plan limits
        assertRowCapacity->>limitsCache: "set(workspaceId, limits, TTL=30s)"
    end
    assertRowCapacity->>assertRowCapacity: wouldExceedRowLimit(limit, currentRowCount, addedRows)
    alt exceeds limit
        assertRowCapacity-->>Caller: throw TableRowLimitError
    else within limit
        assertRowCapacity-->>Caller: void
        Caller->>InsertTx: open transaction and insert rows
        InsertTx->>InsertTx: trigger increments row_count (no cap check)
        InsertTx-->>Caller: inserted rows
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant assertRowCapacity
    participant limitsCache
    participant BillingDB
    participant InsertTx

    Caller->>assertRowCapacity: workspaceId, currentRowCount, addedRows
    assertRowCapacity->>limitsCache: get(workspaceId)
    alt cache hit (within 30s TTL)
        limitsCache-->>assertRowCapacity: cached limits
    else cache miss / expired
        assertRowCapacity->>BillingDB: getWorkspaceBilledAccountUserId + getHighestPrioritySubscription
        BillingDB-->>assertRowCapacity: plan limits
        assertRowCapacity->>limitsCache: "set(workspaceId, limits, TTL=30s)"
    end
    assertRowCapacity->>assertRowCapacity: wouldExceedRowLimit(limit, currentRowCount, addedRows)
    alt exceeds limit
        assertRowCapacity-->>Caller: throw TableRowLimitError
    else within limit
        assertRowCapacity-->>Caller: void
        Caller->>InsertTx: open transaction and insert rows
        InsertTx->>InsertTx: trigger increments row_count (no cap check)
        InsertTx-->>Caller: inserted rows
    end
Loading

Reviews (2): Last reviewed commit: "fix(tables): enforce row limits against ..." | Re-trigger Greptile

Comment thread apps/sim/lib/table/billing.ts
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in 498bd20:

  • Bugbot (high) — multi-batch CSV create could exceed the plan: import-csv create-from-CSV passed the freshly-created table (rowCount frozen at 0) to each batchInsertRows, so every batch was checked as if the table were empty. Now threads the running insert count, mirroring the per-batch check in import-runner.
  • Bugbot (medium) — initialRowCount bypassed the cap: createTable now runs assertRowCapacity for starter rows before opening the tx (a sub-100 env-configured plan limit would otherwise be exceeded).
  • Greptile (P2) — cache could exceed its stated ceiling: cacheLimits now evicts oldest-inserted entries (not just expired ones) when a burst of all-fresh entries sits at the cap, so the Map is a true hard bound. Added a 6k-workspace burst test.
  • Greptile (P2) — upsertRow uses pre-tx rowCount: intentional, per the documented best-effort model (re-reading inside the lock would tighten it at the cost of an extra query); left as-is.

360 tests pass; tsc, lint, and check:api-validation:strict clean.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread apps/sim/lib/copilot/tools/server/table/user-table.ts
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

Round 2 (ce6d431):

  • Bugbot (medium) — copilot batchInsertAll stale row count: third instance of the same multi-batch bug; now threads table.rowCount + inserted into each batch's capacity check (covers both the create and append copilot paths).
  • Greptile (P2) — cache "hard ceiling" not enforced: already addressed in the prior commit — cacheLimits evicts oldest-inserted entries (not just expired) once a burst of all-fresh entries hits the cap, so the Map size never exceeds LIMITS_CACHE_MAX_ENTRIES. The 6,000-workspace burst test in billing.test.ts asserts the bound holds. (The re-review appears to be reading the pre-eviction hunk.)
  • Greptile (P2) — gutter width shifts as rowCount crosses powers of ten: intended — the gutter sizes to the live row count by design; the shift is one column-width step as a table grows. No change.
  • Greptile (P2) — upsertRow pre-tx rowCount: intended best-effort model, as noted. No change.

266 table/copilot tests pass; tsc, lint clean.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread apps/sim/app/api/table/import-csv/route.ts
@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c8f4149. Configure here.

workspaceId,
currentRowCount: existingRowCount + inserted,
addedRows: coerced.length,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace import deletes then caps

High Severity

Async CSV replace deletes all existing rows before streaming inserts, but the new plan row-limit check runs per batch in flush only after that delete. When the file (or an early batch) exceeds the workspace limit, the job fails after the table is already empty or only partially refilled, unlike the sync replace path that validates total size before writing.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c8f4149. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated to this pr.

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator Author

@greptile review

@TheodoreSpeaks TheodoreSpeaks merged commit 597d7ea into staging Jun 18, 2026
16 checks passed
@TheodoreSpeaks TheodoreSpeaks deleted the fix/table-limit-enforcement branch June 18, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant