Skip to content
Open
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
7 changes: 3 additions & 4 deletions apps/docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2602,7 +2602,7 @@
"properties": {
"name": {
"type": "string",
"description": "Column name (alphanumeric and underscores, must start with letter or underscore)"
"description": "Column display name. Any printable characters; no control characters or leading/trailing whitespace."
},
"type": {
"type": "string",
Expand Down Expand Up @@ -5577,9 +5577,8 @@
"properties": {
"name": {
"type": "string",
"description": "Column name. Must start with a letter or underscore.",
"example": "email",
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$"
"description": "Column display name. Any printable characters; no control characters or leading/trailing whitespace.",
"example": "email"
},
"type": {
"type": "string",
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
msg.includes('already exists') ||
msg.includes('maximum column') ||
msg.includes('Invalid column') ||
msg.includes('exceeds maximum')
msg.includes('exceeds maximum') ||
msg.includes("conflicts with another column's id")
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
Expand Down Expand Up @@ -161,6 +162,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
msg.includes('Cannot set unique column') ||
msg.includes('Invalid column') ||
msg.includes('exceeds maximum') ||
msg.includes("conflicts with another column's id") ||
msg.includes('incompatible') ||
msg.includes('duplicate')
) {
Expand Down
60 changes: 30 additions & 30 deletions apps/sim/app/api/table/[tableId]/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ import {
inferColumnType,
markTableJobRunning,
releaseJobClaim,
sanitizeName,
sanitizeColumnName,
type TableDefinition,
type TableSchema,
uniqueColumnName,
validateMapping,
wouldExceedRowLimit,
} from '@/lib/table'
Expand All @@ -55,6 +56,30 @@ interface RouteParams {
params: Promise<{ tableId: string }>
}

/**
* Classifies a thrown import error as caller-caused (→ 400 with the message)
* vs unexpected (→ 500, message hidden). The shared column/row services throw
* bare `Error`s, so this matches on their message markers — one list for every
* catch site in this route so a new marker can't be added to only some of
* them. Interim until those services throw typed errors.
*/
function isImportClientError(message: string): boolean {
return (
message.includes('row limit') ||
message.includes('Insufficient capacity') ||
message.includes('Schema validation') ||
message.includes('must be unique') ||
message.includes('Row size exceeds') ||
message.includes('already exists') ||
message.includes('Invalid column name') ||
message.includes('exceeds maximum length') ||
message.includes('maximum column limit') ||
message.includes("conflicts with another column's id") ||
message.includes('CSV file has no') ||
/^Row \d+:/.test(message)
)
}

export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { tableId } = tableIdParamsSchema.parse(await params)
Expand Down Expand Up @@ -211,14 +236,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
const newColumns: TableSchema['columns'] = []

for (const header of createColumns) {
const base = sanitizeName(header)
let columnName = base
let suffix = 2
while (usedNames.has(columnName.toLowerCase())) {
columnName = `${base}_${suffix}`
suffix++
}
usedNames.add(columnName.toLowerCase())
const columnName = uniqueColumnName(sanitizeColumnName(header), usedNames)
const inferredType = inferColumnType(rows.map((r) => r[header]))
// Pre-assign the id so the prospective schema (used to coerce rows) and
// the persisted column (created in importAppendRows) share the same key.
Expand Down Expand Up @@ -334,15 +352,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
createdColumns: additions.length,
error: message,
})
const isClientError =
message.includes('row limit') ||
message.includes('Insufficient capacity') ||
message.includes('Schema validation') ||
message.includes('must be unique') ||
message.includes('Row size exceeds') ||
message.includes('already exists') ||
message.includes('Invalid column name') ||
/^Row \d+:/.test(message)
const isClientError = isImportClientError(message)
return NextResponse.json(
{
error: isClientError ? message : 'Failed to import CSV',
Expand Down Expand Up @@ -386,14 +396,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
})
} catch (err) {
const message = toError(err).message
const isClientError =
message.includes('row limit') ||
message.includes('Schema validation') ||
message.includes('must be unique') ||
message.includes('Row size exceeds') ||
message.includes('already exists') ||
message.includes('Invalid column name') ||
/^Row \d+:/.test(message)
const isClientError = isImportClientError(message)
if (isClientError) {
return NextResponse.json({ error: message }, { status: 400 })
}
Expand All @@ -405,10 +408,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
const message = toError(error).message
logger.error(`[${requestId}] CSV import into existing table failed:`, error)

const isClientError =
message.includes('CSV file has no') ||
message.includes('already exists') ||
message.includes('Invalid column name')
const isClientError = isImportClientError(message)

return NextResponse.json(
{ error: isClientError ? message : 'Failed to import CSV' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { generateId } from '@sim/utils/id'
import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables'
import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table'
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
import { deriveOutputColumnName } from '@/lib/table/column-naming'
import { uniqueColumnName } from '@/lib/table/column-naming'
import { FieldError } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import type { EnrichmentConfig as EnrichmentDef } from '@/enrichments/types'
import {
Expand Down Expand Up @@ -110,11 +110,9 @@ export function EnrichmentConfig({
}
return seed
}
const taken = new Set(allColumns.map((c) => c.name))
const taken = new Set(allColumns.map((c) => c.name.toLowerCase()))
for (const o of enrichment.outputs) {
const colName = deriveOutputColumnName(o.name, taken)
taken.add(colName)
seed[o.id] = colName
seed[o.id] = uniqueColumnName(o.name, taken)
}
return seed
})
Expand Down Expand Up @@ -192,13 +190,12 @@ export function EnrichmentConfig({
}

const groupId = generateId()
const taken = new Set(allColumns.map((c) => c.name))
const taken = new Set(allColumns.map((c) => c.name.toLowerCase()))
const outputColumns: AddWorkflowGroupBodyInput['outputColumns'] = []
const outputs: WorkflowGroupOutput[] = []
for (const o of enrichment.outputs) {
const desired = (outputNames[o.id] ?? '').trim() || o.name
const colName = deriveOutputColumnName(desired, taken)
taken.add(colName)
const colName = uniqueColumnName(desired, taken)
outputColumns.push({
name: colName,
type: o.type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { ChevronDown } from '@sim/emcn/icons'
import type { WorkflowGroup } from '@/lib/table'
import { TABLE_LIMITS } from '@/lib/table/constants'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { COL_WIDTH, SELECTION_TINT_BG } from '../constants'
import type { ColumnSourceInfo, DisplayColumn } from '../types'
Expand Down Expand Up @@ -278,6 +279,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
ref={renameInputRef}
type='text'
value={renameValue}
maxLength={TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH}
onChange={(e) => onRenameValueChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onRenameSubmit()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ export function WorkflowSidebarBody({
toast.success(`Saved "${targetColumnName}"`)
} else {
// edit-group: full output diff with new-column derivation.
const taken = new Set(allColumns.map((c) => c.name))
const taken = new Set(allColumns.map((c) => c.name.toLowerCase()))
const fullOutputs: WorkflowGroupOutput[] = []
const newOutputColumns: NonNullable<UpdateWorkflowGroupBodyInput['newOutputColumns']> = []
for (const o of orderedOutputs) {
Expand All @@ -696,7 +696,6 @@ export function WorkflowSidebarBody({
fullOutputs.push(existingOut)
} else {
const colName = deriveOutputColumnName(o.path, taken)
taken.add(colName)
fullOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName })
newOutputColumns.push({
name: colName,
Expand All @@ -723,12 +722,11 @@ export function WorkflowSidebarBody({
} else {
// Create path: brand-new group with auto-derived output column names.
const groupId = generateId()
const taken = new Set(allColumns.map((c) => c.name))
const taken = new Set(allColumns.map((c) => c.name.toLowerCase()))
const newOutputColumns: AddWorkflowGroupBodyInput['outputColumns'] = []
const groupOutputs: WorkflowGroupOutput[] = []
for (const o of orderedOutputs) {
const colName = deriveOutputColumnName(o.path, taken)
taken.add(colName)
newOutputColumns.push({
name: colName,
type: columnTypeForLeaf(o.leafType),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ const MAX_EXAMPLES_IN_ERROR = 3
const CSV_PREVIEW_BYTES = 512 * 1024
/**
* Sentinel value for the "Do not import" option in the mapping combobox. The
* whitespace is intentional: valid column names must match `NAME_PATTERN`
* (`/^[a-z_][a-z0-9_]*$/i`), so no real column can share this value.
* embedded NUL is intentional: control characters are invalid in column names
* (`COLUMN_NAME_PATTERN`), so no real column can share this value.
*/
const SKIP_VALUE = '__ skip __'
const SKIP_VALUE = '\u0000skip'
/**
* Sentinel for the "Create new column" option. Same whitespace trick as
* Sentinel for the "Create new column" option. Same control-character trick as
* `SKIP_VALUE` to avoid colliding with any valid column name.
*/
const CREATE_VALUE = '__ create __'
const CREATE_VALUE = '\u0000create'

/**
* Converts the verbose backend error messages into a short, human-friendly
Expand Down
26 changes: 25 additions & 1 deletion apps/sim/lib/api/contracts/tables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,31 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { tableEventStreamQuerySchema } from '@/lib/api/contracts/tables'
import { tableColumnSchema, tableEventStreamQuerySchema } from '@/lib/api/contracts/tables'

describe('tableColumnSchema', () => {
it('accepts display names with spaces, digits-first, punctuation, and unicode', () => {
for (const name of ['First Name', '2024 Revenue', 'price ($)', 'caf\u00e9']) {
expect(tableColumnSchema.safeParse({ name, type: 'string' }).success).toBe(true)
}
})

it('rejects invisible characters, edge whitespace, leading $, and the CSV-dialog sentinels', () => {
const bad = [' leading', 'trailing ', 'a\u0000b', 'zero\u200bwidth', '$or', '\u0000skip']
for (const name of bad) {
expect(tableColumnSchema.safeParse({ name, type: 'string' }).success).toBe(false)
}
})

it('accepts identifier-shaped column ids and rejects non-identifier ids', () => {
const good = { name: 'x', type: 'string' }
expect(tableColumnSchema.safeParse({ ...good, id: 'col_ab12' }).success).toBe(true)
expect(tableColumnSchema.safeParse({ ...good, id: 'legacy_name' }).success).toBe(true)
for (const id of ["a-b'", 'has space', '1leading', '']) {
expect(tableColumnSchema.safeParse({ ...good, id }).success).toBe(false)
}
})
})

describe('tableEventStreamQuerySchema', () => {
it('parses an explicit cursor', () => {
Expand Down
30 changes: 24 additions & 6 deletions apps/sim/lib/api/contracts/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import type {
TableRow,
TableRowsCursor,
} from '@/lib/table'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import {
COLUMN_NAME_PATTERN,
COLUMN_NAME_RULE,
COLUMN_TYPES,
NAME_PATTERN,
TABLE_LIMITS,
} from '@/lib/table/constants'
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'

export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
Expand All @@ -25,7 +31,7 @@ export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
export const columnTypeSchema = z.enum(COLUMN_TYPES)

/**
* Identifier for tables/columns: starts with letter or underscore, contains
* Identifier for tables: starts with letter or underscore, contains
* only alphanumerics + underscores, capped at `MAX_TABLE_NAME_LENGTH`.
*/
const tableNameSchema = z
Expand All @@ -47,9 +53,21 @@ const columnNameSchema = z
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
)
.regex(COLUMN_NAME_PATTERN, COLUMN_NAME_RULE)

/**
* Stable column id — the JSONB row-data storage key, interpolated into SQL
* paths after a `NAME_PATTERN` guard. Enforce the identifier shape at the
* boundary so a caller-supplied id (undo re-create, schema copy) fails fast at
* write time instead of wedging every later filter/sort/unique-check.
*/
const columnIdSchema = z
.string()
.min(1, 'Column id cannot be empty')
.max(64, 'Column id must be 64 characters or less')
.regex(
NAME_PATTERN,
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
'Column id must start with a letter or underscore and contain only alphanumeric characters and underscores'
)

const descriptionSchema = z
Expand Down Expand Up @@ -80,7 +98,7 @@ export const getTableQuerySchema = z.object({

export const tableColumnSchema = z.object({
/** Stable column id (server-assigned). Absent on legacy/ pre-backfill columns. */
id: z.string().optional(),
id: columnIdSchema.optional(),
name: columnNameSchema,
type: columnTypeSchema,
required: z.boolean().optional().default(false),
Expand Down Expand Up @@ -115,7 +133,7 @@ export const createTableColumnBodySchema = z.object({
column: z.object({
// Optional stable id — first-party undo of a delete re-creates the column
// with its original id so saved (id-keyed) cell data restores correctly.
id: z.string().optional(),
id: columnIdSchema.optional(),
name: columnNameSchema,
type: columnTypeSchema,
required: z.boolean().optional(),
Expand Down Expand Up @@ -1020,7 +1038,7 @@ const workflowGroupInputMappingSchema = z.object({
})

const workflowGroupOutputColumnSchema = z.object({
name: z.string().min(1),
name: columnNameSchema,
type: columnTypeSchema,
required: z.boolean().optional(),
unique: z.boolean().optional(),
Expand Down
Loading
Loading