Files
simstudioai--sim/apps/sim/app/api/table/utils.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

284 lines
9.2 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { permissionSatisfies } from '@sim/platform-authz/workspace'
import { toError } from '@sim/utils/errors'
import { NextResponse } from 'next/server'
import {
createTableColumnBodySchema,
deleteTableColumnBodySchema,
updateTableColumnBodySchema,
} from '@/lib/api/contracts/tables'
import type { MultipartError } from '@/lib/core/utils/multipart'
import type { ColumnDefinition, Filter, TableDefinition } from '@/lib/table'
import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table'
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
/**
* Validates a `filter` against the table's column schema, returning a 400 response on a bad field
* (or `null` when the filter is valid or absent). Shared by the routes that accept a filter
* (`delete-async`, `columns/run`) so a bad field fails fast with a clear message.
*/
export function tableFilterError(
filter: Filter | undefined,
columns: ColumnDefinition[]
): NextResponse | null {
if (!filter) return null
try {
buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, columns)
return null
} catch (error) {
if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
throw error
}
}
const logger = createLogger('TableUtils')
/**
* 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
while (current instanceof Error && current.cause instanceof Error) {
current = current.cause
}
return toError(current).message
}
/**
* 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',
'Insufficient capacity',
'Schema validation',
'must be unique',
'must be valid',
'must be string',
'must be number',
'must be boolean',
'unique column',
'Unique constraint violation',
'Row size exceeds',
'conflictTarget',
'Upsert requires',
'Rows not found',
'Filter is required',
] as const
/**
* Maps a known user-facing row-write failure to a 400 carrying the real message
* (so client toasts can show the actual reason); `null` when the error is
* unrecognized and the caller should log it and return its generic 500.
*/
export function rowWriteErrorResponse(error: unknown): NextResponse | null {
const message = rootErrorMessage(error)
if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) {
return NextResponse.json({ error: message }, { status: 400 })
}
return null
}
/**
* Next.js buffers the request body for the proxy and silently truncates it past this
* size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV
* import routes reject bodies over the cap up front; larger files use the async
* direct-to-storage path instead.
*/
export const CSV_IMPORT_PROXY_BODY_CAP_BYTES = 10 * 1024 * 1024
/** 413 response when a synchronous CSV upload would exceed (and be truncated at) the proxy cap; `null` otherwise. */
export function csvProxyBodyCapResponse(request: { headers: Headers }): NextResponse | null {
const contentLength = Number(request.headers.get('content-length') ?? 0)
if (contentLength > CSV_IMPORT_PROXY_BODY_CAP_BYTES) {
return NextResponse.json(
{
error:
'File too large to import through the server. Files over 10MB import in the background.',
},
{ status: 413 }
)
}
return null
}
/** Maps a {@link MultipartError} from the streaming CSV parser to its HTTP response. */
export function multipartErrorResponse(error: MultipartError): NextResponse {
if (error.code === 'FILE_TOO_LARGE') {
return NextResponse.json({ error: 'CSV import file exceeds maximum size' }, { status: 413 })
}
const message =
error.code === 'NO_FILE' ? 'CSV file is required' : `Invalid CSV upload: ${error.message}`
return NextResponse.json({ error: message }, { status: 400 })
}
interface TableAccessResult {
hasAccess: true
table: TableDefinition
}
interface TableAccessDenied {
hasAccess: false
notFound?: boolean
reason?: string
}
export type TableAccessCheck = TableAccessResult | TableAccessDenied
export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 }
interface ApiErrorResponse {
error: string
details?: unknown
}
/**
* Check if a user has read access to a table.
* Read access requires any workspace permission (read, write, or admin).
*/
async function checkTableAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
const table = await getTableById(tableId)
if (!table) {
return { hasAccess: false, notFound: true }
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (userPermission !== null) {
return { hasAccess: true, table }
}
return { hasAccess: false, reason: 'User does not have access to this table' }
}
/**
* Check if a user has write access to a table.
* Write access requires write or admin workspace permission.
*/
async function checkTableWriteAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
const table = await getTableById(tableId)
if (!table) {
return { hasAccess: false, notFound: true }
}
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (permissionSatisfies(userPermission, 'write')) {
return { hasAccess: true, table }
}
return { hasAccess: false, reason: 'User does not have write access to this table' }
}
/**
* Access check returning `{ ok, table }` or `{ ok: false, status }`.
* Uses workspace permissions only.
*/
export async function checkAccess(
tableId: string,
userId: string,
level: 'read' | 'write' | 'admin' = 'read'
): Promise<AccessResult> {
const table = await getTableById(tableId)
if (!table) {
return { ok: false, status: 404 }
}
const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
const hasAccess = permissionSatisfies(permission, level)
return hasAccess ? { ok: true, table } : { ok: false, status: 403 }
}
export function accessError(
result: { ok: false; status: 404 | 403 },
requestId: string,
context?: string
): NextResponse {
const message = result.status === 404 ? 'Table not found' : 'Access denied'
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
return NextResponse.json({ error: message }, { status: result.status })
}
/**
* Converts a TableAccessDenied result to an appropriate HTTP response.
* Use with checkTableAccess or checkTableWriteAccess.
*/
export function tableAccessError(
result: TableAccessDenied,
requestId: string,
context?: string
): NextResponse {
const status = result.notFound ? 404 : 403
const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied')
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
return NextResponse.json({ error: message }, { status })
}
async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
const table = await getTableById(tableId)
return table?.workspaceId === workspaceId
}
export function errorResponse(
message: string,
status: number,
details?: unknown
): NextResponse<ApiErrorResponse> {
const body: ApiErrorResponse = { error: message }
if (details !== undefined) {
body.details = details
}
return NextResponse.json(body, { status })
}
export function badRequestResponse(message: string, details?: unknown) {
return errorResponse(message, 400, details)
}
export function unauthorizedResponse(message = 'Authentication required') {
return errorResponse(message, 401)
}
export function forbiddenResponse(message = 'Access denied') {
return errorResponse(message, 403)
}
export function notFoundResponse(message = 'Resource not found') {
return errorResponse(message, 404)
}
export function serverErrorResponse(message = 'Internal server error') {
return errorResponse(message, 500)
}
/**
* Re-exports from `lib/api/contracts/tables` so existing routes that import
* these names keep working while sharing a single source of truth.
*/
export const CreateColumnSchema = createTableColumnBodySchema
export const UpdateColumnSchema = updateTableColumnBodySchema
export const DeleteColumnSchema = deleteTableColumnBodySchema
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
// Preserve the stable column id — it's the row-data storage key, so dropping
// it makes clients fall back to `name` and miss id-keyed cell values.
...(col.id ? { id: col.id } : {}),
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
}
}