Files
simstudioai--sim/apps/sim/lib/table/billing.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

259 lines
9.6 KiB
TypeScript

/**
* Billing helpers for table feature limits.
*
* Uses workspace billing account to determine plan-based limits.
*/
import { createLogger } from '@sim/logger'
import { maybeNotifyLimit } from '@/lib/billing/core/limit-notifications'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getPlanTypeForLimits } from '@/lib/billing/plan-helpers'
import { getTablePlanLimits, type PlanName, type TablePlanLimits } from '@/lib/table/constants'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
const logger = createLogger('TableBilling')
/** Warn band; an insert that crosses up into it (or up into 100%) triggers a notify. */
const TABLE_WARN_PERCENT = 80
const TABLE_REACHED_PERCENT = 100
/** Whether adding rows pushed the count UP across `threshold` (pre below, post at/above). */
function crossedUp(prePct: number, postPct: number, threshold: number): boolean {
return prePct < threshold && postPct >= threshold
}
/**
* Best-effort table row-limit email after an accepted insert. Resolves the
* workspace's billed account, then delegates scope resolution + dedup + send to
* {@link maybeNotifyLimit}. Never throws.
*/
async function maybeNotifyTableRowLimit(
workspaceId: string,
projectedRowCount: number,
limit: number
): Promise<void> {
try {
const billedUserId = await getWorkspaceBilledAccountUserId(workspaceId)
if (!billedUserId) return
await maybeNotifyLimit({
category: 'tables',
billedUserId,
workspaceId,
currentUsage: projectedRowCount,
limit,
usageLabel: `${projectedRowCount.toLocaleString('en-US')} rows`,
limitLabel: `${limit.toLocaleString('en-US')} rows`,
})
} catch (error) {
logger.error('Error evaluating table row-limit notification:', error)
}
}
/**
* Fire-and-forget the table row-limit threshold email for an accepted insert.
* Edge-triggered: it only does any billing work when the write CROSSES UP into
* the warn (80%) or reached (100%) band — not on every near-limit insert — so a
* table sitting at e.g. 90% being inserted into pays nothing until it crosses
* 100%. Shared by every insert path ({@link assertRowCapacity} and the
* transactional upsert/import branches that check capacity with
* {@link wouldExceedRowLimit} instead). Pass the pre-insert `currentRowCount` and
* `addedRows` so both the pre and post percentages are known.
*
* Tables warn once per threshold and do not re-arm: a table that hit a threshold
* then dropped (via deletes, which have no notify hook) won't re-warn on a later
* climb. Deliberate trade-off — re-arm is storage-only (its single decrement
* hook) to avoid per-delete billing-table reads. The atomic claim still dedups
* concurrent crossings (no race).
*/
export function notifyTableRowUsage(params: {
workspaceId: string
currentRowCount: number
addedRows: number
limit: number
}): void {
if (params.limit <= 0) return
const prePct = (params.currentRowCount / params.limit) * 100
const postPct = ((params.currentRowCount + params.addedRows) / params.limit) * 100
if (
crossedUp(prePct, postPct, TABLE_WARN_PERCENT) ||
crossedUp(prePct, postPct, TABLE_REACHED_PERCENT)
) {
void maybeNotifyTableRowLimit(
params.workspaceId,
params.currentRowCount + params.addedRows,
params.limit
)
}
}
/**
* Plan lookups hit billing + subscription tables (2-3 queries). Row-limit checks
* run on every insert, so a short TTL keeps the hot path off the DB. Plan changes
* are rare and enforcement is best-effort, so brief staleness is acceptable.
*/
const LIMITS_CACHE_TTL_MS = 30_000
/** Hard ceiling on cached workspaces; a sweep drops expired entries before this is exceeded so the Map can't grow unbounded. */
const LIMITS_CACHE_MAX_ENTRIES = 5_000
const limitsCache = new Map<string, { limits: TablePlanLimits; expiresAt: number }>()
/**
* Gets the table limits for a workspace based on its billing plan.
*
* Uses the workspace's billed account user to determine the subscription plan,
* then returns the corresponding table limits. Resolved limits are cached for
* {@link LIMITS_CACHE_TTL_MS}; the free-tier error fallback is never cached.
*
* @param workspaceId - The workspace ID to get limits for
* @returns Table limits based on the workspace's billing plan
*/
export async function getWorkspaceTableLimits(workspaceId: string): Promise<TablePlanLimits> {
const cached = limitsCache.get(workspaceId)
if (cached) {
if (cached.expiresAt > Date.now()) return cached.limits
limitsCache.delete(workspaceId)
}
const planLimits = getTablePlanLimits()
try {
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
if (!billedAccountUserId) {
logger.warn('No billed account found for workspace, using free tier limits', { workspaceId })
cacheLimits(workspaceId, planLimits.free)
return planLimits.free
}
const subscription = await getHighestPrioritySubscription(billedAccountUserId)
const planName = getPlanTypeForLimits(subscription?.plan) as PlanName
const limits = planLimits[planName] ?? planLimits.free
logger.info('Retrieved workspace table limits', {
workspaceId,
billedAccountUserId,
planName,
limits,
})
cacheLimits(workspaceId, limits)
return limits
} catch (error) {
logger.error('Error getting workspace table limits, falling back to free tier', {
workspaceId,
error,
})
return planLimits.free
}
}
function cacheLimits(workspaceId: string, limits: TablePlanLimits): void {
// Keep the Map bounded for a new key: sweep expired entries, then (if a burst of
// all-fresh entries still sits at the cap) evict oldest-inserted ones. Map iteration
// is insertion order, so the first key is the oldest. Net: size never exceeds the cap.
if (limitsCache.size >= LIMITS_CACHE_MAX_ENTRIES && !limitsCache.has(workspaceId)) {
const now = Date.now()
for (const [key, entry] of limitsCache) {
if (entry.expiresAt <= now) limitsCache.delete(key)
}
while (limitsCache.size >= LIMITS_CACHE_MAX_ENTRIES) {
const oldest = limitsCache.keys().next().value
if (oldest === undefined) break
limitsCache.delete(oldest)
}
}
limitsCache.set(workspaceId, { limits, expiresAt: Date.now() + LIMITS_CACHE_TTL_MS })
}
/**
* Thrown by {@link assertRowCapacity} when a write would exceed the workspace's
* current plan row limit. The message includes the lowercase `row limit` token so
* `rowWriteErrorResponse` maps it to a 400 toast carrying the real reason.
*/
export class TableRowLimitError extends Error {
constructor(readonly limit: number) {
super(
`This table has reached its row limit (${limit.toLocaleString('en-US')} rows) on your current plan.`
)
this.name = 'TableRowLimitError'
}
}
/**
* Whether adding `addedRows` to `currentRowCount` would cross `limit`. A negative
* limit means unlimited. Single source of truth for the comparison so callers that
* fetch the limit themselves (e.g. inside a transaction, or to build a custom
* message) stay consistent with {@link assertRowCapacity}.
*/
export function wouldExceedRowLimit(
limit: number,
currentRowCount: number,
addedRows: number
): boolean {
return limit >= 0 && currentRowCount + addedRows > limit
}
/**
* Best-effort capacity check against the workspace's CURRENT plan limit.
*
* Not transactional: reads the (trigger-maintained, possibly slightly stale) row
* count and the cached plan limit outside any lock, so concurrent writers may
* overshoot by a small amount. It rejects once the count is at/over the limit, so
* a table can't run away past its plan.
*
* Resolve the limit OUTSIDE any open transaction — `getMaxRowsPerTable` may hit the
* billing/subscription tables on the global pool, and doing that while holding a tx
* connection (and locks) risks pool starvation. Callers already inside a tx should
* fetch the limit up front and use {@link wouldExceedRowLimit} instead.
*
* Pure check (no side effects): returns the resolved limit so callers can fire
* {@link notifyTableRowUsage} AFTER their insert commits — a pre-commit notify
* would email (and burn the dedup claim) for a write that later rolls back.
*
* @returns the resolved plan row limit (-1 for unlimited)
* @throws {TableRowLimitError} if `currentRowCount + addedRows` exceeds the limit
*/
export async function assertRowCapacity(params: {
workspaceId: string
currentRowCount: number
addedRows: number
}): Promise<number> {
const limit = await getMaxRowsPerTable(params.workspaceId)
if (wouldExceedRowLimit(limit, params.currentRowCount, params.addedRows)) {
throw new TableRowLimitError(limit)
}
return limit
}
/**
* Checks if a workspace can create more tables based on its plan limits.
*
* @param workspaceId - The workspace ID to check
* @param currentTableCount - The current number of tables in the workspace
* @returns Object with canCreate boolean and limit info
*/
async function canCreateTable(
workspaceId: string,
currentTableCount: number
): Promise<{ canCreate: boolean; maxTables: number; currentCount: number }> {
const limits = await getWorkspaceTableLimits(workspaceId)
return {
canCreate: currentTableCount < limits.maxTables,
maxTables: limits.maxTables,
currentCount: currentTableCount,
}
}
/**
* Gets the maximum rows allowed per table for a workspace based on its plan.
*
* @param workspaceId - The workspace ID
* @returns Maximum rows per table (-1 for unlimited)
*/
export async function getMaxRowsPerTable(workspaceId: string): Promise<number> {
const limits = await getWorkspaceTableLimits(workspaceId)
return limits.maxRowsPerTable
}