diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index ffcef8ce465..2cf4e8dd00d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -17,7 +17,7 @@ import { } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' import { SelectOptionsEditor } from '../select-field' -import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' +import { columnTypeOptionsForTable } from './column-types' /** Whether a column type carries an option set. */ function isSelectType(type: ColumnDefinition['type']): boolean { @@ -52,6 +52,7 @@ interface ColumnConfigSidebarProps { onClose: () => void /** Existing column record for `mode: 'edit'`; ignored otherwise. */ existingColumn: ColumnDefinition | null + allColumns: readonly ColumnDefinition[] workspaceId: string tableId: string /** Notify parent of a rename so it can rewrite local `columnOrder` / @@ -102,6 +103,7 @@ function ColumnConfigBody({ config, onClose, existingColumn, + allColumns, workspaceId, tableId, onColumnRename, @@ -274,11 +276,14 @@ function ColumnConfigBody({
Type ({ - label: o.label, - value: o.type, - icon: o.icon, - }))} + options={columnTypeOptionsForTable(allColumns, existingColumn) + .filter((option) => option.type !== 'workflow') + .map((option) => ({ + label: option.label, + value: option.type, + icon: option.icon, + disabled: option.disabledReason !== undefined, + }))} value={typeInput} onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} placeholder='Select type' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts new file mode 100644 index 00000000000..f22a77cd149 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it } from 'vitest' +import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types' +import { + COLUMN_TYPE_OPTIONS, + columnTypeOptionsForTable, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types' + +const option = COLUMN_TYPE_OPTIONS.find((candidate) => candidate.type === 'string') +if (!option) throw new Error('String column type option is missing') +const originalMaxPerTable = option.maxPerTable +const definition = COLUMN_TYPE_REGISTRY.string +const originalDefinitionMaxPerTable = definition.maxPerTable + +afterEach(() => { + if (originalMaxPerTable === undefined) { + Reflect.deleteProperty(option, 'maxPerTable') + } else { + option.maxPerTable = originalMaxPerTable + } + + if (originalDefinitionMaxPerTable === undefined) { + Reflect.deleteProperty(definition, 'maxPerTable') + } else { + Object.assign(definition, { maxPerTable: originalDefinitionMaxPerTable }) + } +}) + +describe('column type picker limits', () => { + it('keeps a limited type visible but disables it once the limit is reached', () => { + option.maxPerTable = 1 + Object.assign(definition, { maxPerTable: 1 }) + + const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }]) + const stringOption = result.find((candidate) => candidate.type === 'string') + + expect(stringOption?.disabledReason).toBe('Only one Text column allowed per table') + }) + + it('keeps the current type selectable while editing its existing column', () => { + option.maxPerTable = 1 + Object.assign(definition, { maxPerTable: 1 }) + const currentColumn = { name: 'first', type: 'string' } as const + + const result = columnTypeOptionsForTable([currentColumn], currentColumn) + const stringOption = result.find((candidate) => candidate.type === 'string') + + expect(stringOption?.disabledReason).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 2f235137f17..4a4f7229113 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -1,7 +1,7 @@ import type React from 'react' import { PlayOutline } from '@sim/emcn/icons' import type { ColumnDefinition } from '@/lib/table' -import { ALL_COLUMN_TYPES } from '@/lib/table/column-types' +import { ALL_COLUMN_TYPES, wouldExceedColumnTypeLimit } from '@/lib/table/column-types' /** * UI-only column type. `'workflow'` is the virtual entry users pick from the @@ -14,6 +14,8 @@ export interface ColumnTypeOption { type: SidebarColumnType label: string icon: React.ComponentType<{ className?: string }> + maxPerTable?: number + disabledReason?: string } /** @@ -26,9 +28,30 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ type: definition.id, label: definition.label, icon: definition.icon, + maxPerTable: definition.maxPerTable, })), { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] -/** Plain column types (no workflow). Used by ``'s type combobox in edit mode. */ -export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') +function columnTypeLimitMessage(label: string, maxPerTable: number): string { + return maxPerTable === 1 + ? `Only one ${label} column allowed per table` + : `Only ${maxPerTable} ${label} columns allowed per table` +} + +/** Picker entries with unavailable cardinality-limited types marked as disabled. */ +export function columnTypeOptionsForTable( + columns: readonly ColumnDefinition[], + currentColumn?: ColumnDefinition | null +): ColumnTypeOption[] { + return COLUMN_TYPE_OPTIONS.map((option) => { + if (option.type === 'workflow') return option + if (currentColumn?.type === option.type) return option + if (option.maxPerTable === undefined) return option + if (!wouldExceedColumnTypeLimit(columns, option.type, 1)) return option + return { + ...option, + disabledReason: columnTypeLimitMessage(option.label, option.maxPerTable), + } + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts index e458001136d..27e68ffaf5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts @@ -3,6 +3,6 @@ export { ColumnConfigSidebar } from './column-config-sidebar' export { COLUMN_TYPE_OPTIONS, type ColumnTypeOption, - PLAIN_COLUMN_TYPE_OPTIONS, + columnTypeOptionsForTable, type SidebarColumnType, } from './column-types' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx index 2e9b21332dc..3c79f2fb93d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -11,15 +11,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, Plus, + Tooltip, } from '@sim/emcn' import { Sparkles } from '@sim/emcn/icons' import type { ColumnDefinition } from '@/lib/table' -import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar' +import { type ColumnTypeOption, columnTypeOptionsForTable } from '../column-config-sidebar' const CELL_HEADER = 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' interface NewColumnDropdownProps { + columns: readonly ColumnDefinition[] /** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders * the in-table column-header `` trigger. Same dropdown content either way. */ trigger: 'header' | 'inline-header' @@ -37,12 +39,49 @@ interface NewColumnDropdownProps { onBlocked: () => void } +interface ColumnTypeMenuItemProps { + option: ColumnTypeOption + onSelect: () => void +} + +function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) { + const Icon = option.icon + const item = ( + { + if (option.disabledReason) { + event.preventDefault() + return + } + onSelect() + }} + > + + {option.label} + + ) + + if (!option.disabledReason) return item + + return ( + + {item} + {option.disabledReason} + + ) +} + /** * "+ New column" dropdown — the single entry point for creating a column. * Lists every column type plus "Workflow" and "Enrichments"; picking a type * opens the right sidebar pre-seeded. */ export function NewColumnDropdown({ + columns, trigger, disabled, onPickType, @@ -98,18 +137,12 @@ export function NewColumnDropdown({ - {COLUMN_TYPE_OPTIONS.map((option) => { - const Icon = option.icon + {columnTypeOptionsForTable(columns).map((option) => { const onSelect = option.type === 'workflow' ? onPickWorkflow : () => onPickType(option.type as ColumnDefinition['type']) - return ( - - - {option.label} - - ) + return })} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 379f94465c4..ba1d53aa9b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -4833,6 +4833,7 @@ export function TableGrid({ })} {userPermissions.canEdit && ( getColumnId(c) === columnConfig.columnName) ?? null) diff --git a/apps/sim/lib/table/column-types/extension-points.test.ts b/apps/sim/lib/table/column-types/extension-points.test.ts new file mode 100644 index 00000000000..84484a2ae6d --- /dev/null +++ b/apps/sim/lib/table/column-types/extension-points.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it } from 'vitest' +import { + COLUMN_TYPE_REGISTRY, + validateColumnTypeLimits, + valueForTypeConversion, + wouldExceedColumnTypeLimit, +} from '@/lib/table/column-types' +import type { ColumnDefinition } from '@/lib/table/types' + +const definition = COLUMN_TYPE_REGISTRY.string +const originalMaxPerTable = definition.maxPerTable +const originalValueForConversion = definition.valueForConversion + +function restoreOptionalProperty(key: 'maxPerTable' | 'valueForConversion', value: unknown) { + if (value === undefined) { + Reflect.deleteProperty(definition, key) + return + } + Object.assign(definition, { [key]: value }) +} + +afterEach(() => { + restoreOptionalProperty('maxPerTable', originalMaxPerTable) + restoreOptionalProperty('valueForConversion', originalValueForConversion) +}) + +describe('column type extension points', () => { + it('enforces registry-declared per-table limits', () => { + Object.assign(definition, { maxPerTable: 1 }) + const columns: ColumnDefinition[] = [ + { name: 'first', type: 'string' }, + { name: 'second', type: 'string' }, + ] + + expect(wouldExceedColumnTypeLimit(columns.slice(0, 1), 'string', 1)).toBe(true) + expect(validateColumnTypeLimits(columns)).toEqual([ + `A table can have at most 1 ${definition.label} column`, + ]) + }) + + it('lets the source type normalize a value before conversion', () => { + Object.assign(definition, { + valueForConversion: (_value: unknown, target: ColumnDefinition) => + target.type === 'number' ? 42 : 'unchanged', + }) + + expect( + valueForTypeConversion( + 'stored-value', + { name: 'source', type: 'string' }, + { name: 'target', type: 'number' } + ) + ).toBe(42) + expect( + valueForTypeConversion( + 'stored-value', + { name: 'source', type: 'number' }, + { name: 'target', type: 'string' } + ) + ).toBe('stored-value') + }) + + it('preserves an intentional null from source normalization', () => { + Object.assign(definition, { + valueForConversion: () => null, + }) + + expect( + valueForTypeConversion( + 'stored-value', + { name: 'source', type: 'string' }, + { name: 'target', type: 'number' } + ) + ).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/column-types/registry.ts b/apps/sim/lib/table/column-types/registry.ts index 8bc336a1dc1..9bc1de55dbd 100644 --- a/apps/sim/lib/table/column-types/registry.ts +++ b/apps/sim/lib/table/column-types/registry.ts @@ -90,6 +90,16 @@ export function isValueCompatible(value: unknown, target: ColumnDefinition): boo return definition.coerce(value as JsonValue, target).ok } +/** Applies source-owned normalization before a value is converted to another type. */ +export function valueForTypeConversion( + value: JsonValue, + source: ColumnDefinition, + target: ColumnDefinition +): JsonValue { + const normalized = columnTypeOf(source).valueForConversion?.(value, target) + return normalized === undefined ? value : normalized +} + /** This type's own metadata errors; types carrying no metadata report none. */ export function validateTypeMetadata(column: ColumnDefinition): string[] { return columnTypeOf(column).validateDefinition?.(column) ?? [] @@ -115,3 +125,31 @@ export function typeMetadataOf(column: ColumnDefinition): Partial | null { return columnTypeOf(column).filterOperatorsFor?.(column) ?? null } + +/** Schema-level cardinality errors declared by column type definitions. */ +export function validateColumnTypeLimits(columns: readonly ColumnDefinition[]): string[] { + const errors: string[] = [] + for (const definition of ALL_COLUMN_TYPES) { + if (definition.maxPerTable === undefined) continue + if (wouldExceedColumnTypeLimit(columns, definition.id)) { + errors.push(`A table can have at most ${definition.maxPerTable} ${definition.label} column`) + } + } + return errors +} + +/** Whether adding columns of a type would exceed its registry-declared table limit. */ +export function wouldExceedColumnTypeLimit( + columns: readonly ColumnDefinition[], + type: ColumnType, + additionalColumns = 0 +): boolean { + const definition = COLUMN_TYPE_REGISTRY[type] + if (definition.maxPerTable === undefined) return false + + const count = columns.reduce( + (total, column) => total + (column.type === type ? 1 : 0), + additionalColumns + ) + return count > definition.maxPerTable +} diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index f481cea0a28..ad893acb809 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -20,6 +20,7 @@ */ import type React from 'react' +import type { NormalizeDateCellOptions } from '@/lib/table/dates' import type { ColumnDefinition, JsonValue } from '@/lib/table/types' /** @@ -72,6 +73,8 @@ export interface ColumnTypeDefinition { /** Human label in the type picker, column header menu, and docs. */ readonly label: string + /** Maximum columns of this type a table may contain. Omitted when unlimited. */ + readonly maxPerTable?: number /** Type icon. A component reference only — never invoked server-side. */ readonly icon: React.ComponentType<{ className?: string }> /** @@ -157,7 +160,14 @@ export interface ColumnTypeDefinition { * implementation — the server calls it before persisting and the grid calls * it to fill the optimistic cache, so the two can no longer disagree. */ - coerce(value: JsonValue, column: ColumnDefinition): CoerceResult + coerce( + value: JsonValue, + column: ColumnDefinition, + context?: NormalizeDateCellOptions + ): CoerceResult + + /** Source-owned normalization applied before checking or rewriting a type conversion. */ + valueForConversion?(value: JsonValue, target: ColumnDefinition): JsonValue /** Validates a stored cell's shape. Returns an error message, or null when valid. */ validateCell(value: JsonValue, column: ColumnDefinition): string | null @@ -203,7 +213,11 @@ export interface ColumnTypeDefinition { formatForDisplay(value: unknown, column: ColumnDefinition): string /** Stored value → the text an editor input starts with. */ - formatForInput(value: unknown, column: ColumnDefinition): string + formatForInput( + value: unknown, + column: ColumnDefinition, + context?: NormalizeDateCellOptions + ): string /** * Metadata stamped onto a newly created column of this type, so the schema diff --git a/apps/sim/lib/table/columns/retype-cell.test.ts b/apps/sim/lib/table/columns/retype-cell.test.ts index 479563fc74b..b1b6a74d888 100644 --- a/apps/sim/lib/table/columns/retype-cell.test.ts +++ b/apps/sim/lib/table/columns/retype-cell.test.ts @@ -2,13 +2,25 @@ * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types' import { retypeCellRewrite } from '@/lib/table/columns/service' import type { ColumnDefinition } from '@/lib/table/types' const column = (over: Partial): ColumnDefinition => ({ name: 'col', type: 'string', ...over }) as ColumnDefinition +const sourceDefinition = COLUMN_TYPE_REGISTRY.string +const originalValueForConversion = sourceDefinition.valueForConversion + +afterEach(() => { + if (originalValueForConversion === undefined) { + Reflect.deleteProperty(sourceDefinition, 'valueForConversion') + return + } + Object.assign(sourceDefinition, { valueForConversion: originalValueForConversion }) +}) + describe('retypeCellRewrite', () => { it('preserves an empty string the target type can hold', () => { // `''` is a real stored value: `coerceRowValues` keeps it for `string`, and @@ -32,6 +44,29 @@ describe('retypeCellRewrite', () => { expect(retypeCellRewrite('true', column({ type: 'boolean' }))).toEqual({ value: true }) }) + it('writes back null produced by source normalization', () => { + Object.assign(sourceDefinition, { valueForConversion: () => null }) + + expect( + retypeCellRewrite('stored-value', column({ type: 'number' }), column({ type: 'string' })) + ).toEqual({ value: null }) + }) + + it('coerces source-normalized values into select storage', () => { + Object.assign(sourceDefinition, { valueForConversion: () => 'Choice' }) + + expect( + retypeCellRewrite( + 'stored-value', + column({ + type: 'select', + options: [{ id: 'opt_choice', name: 'Choice' }], + }), + column({ type: 'string' }) + ) + ).toEqual({ value: 'opt_choice' }) + }) + it('skips a cell whose stored value already matches the coercion', () => { expect(retypeCellRewrite('kept', column({ type: 'json' }))).toBeNull() expect(retypeCellRewrite(3, column({ type: 'json' }))).toBeNull() diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index b68ce8393ac..cfb5f349fa6 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -27,6 +27,7 @@ import { columnTypeOf, isValueCompatible, TYPE_SPECIFIC_COLUMN_KEYS, + valueForTypeConversion, } from '@/lib/table/column-types' import { migrationFrom, @@ -767,17 +768,24 @@ export function applyPendingRename( */ export function retypeCellRewrite( value: unknown, - target: ColumnDefinition + target: ColumnDefinition, + source?: ColumnDefinition ): { value: JsonValue } | null { if (value === null || value === undefined) return null - if (!isValueCompatibleWithColumn(value, target)) { + const effective = source + ? valueForTypeConversion(value as JsonValue, source, target) + : (value as JsonValue) + + if (effective === null) return { value: null } + + if (!isValueCompatibleWithColumn(effective, target)) { // Incompatible non-blanks never reach here: the compatibility scan already // refused the whole conversion for them. - return value === '' ? { value: null } : null + return effective === '' ? { value: null } : null } - const coerced = columnTypeById(target.type).coerce(value as JsonValue, target) + const coerced = columnTypeById(target.type).coerce(effective, target) if (coerced.ok && !Object.is(coerced.value, value)) return { value: coerced.value } return null } @@ -913,6 +921,7 @@ export async function updateColumnType( const isSelectType = data.newType === 'select' const targetOptions = data.options ?? column.options ?? [] const targetMultiple = data.multiple ?? column.multiple + const sourceNormalizesConversion = columnTypeOf(column).valueForConversion !== undefined // Leaving `select` behind: stored cells hold option ids, which mean nothing // once the column is text/number/etc. Check compatibility against the option // NAME — that's what the cell will actually become (migrated below). @@ -944,6 +953,12 @@ export async function updateColumnType( isSelectType, targetMultiple: !!targetMultiple, }) + const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c)) + const updatedColumns = renamedColumns.map((c, i) => + i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + assertValidSchema(updatedSchema, table.metadata?.columnOrder) let incompatibleCount = 0 let blankCount = 0 @@ -972,7 +987,7 @@ export async function updateColumnType( const effective = convertingAwayFromSelect ? selectValueForConversion(column, value) - : value + : valueForTypeConversion(value as JsonValue, column, convertedColumn) if (!isValueCompatibleWithColumn(effective, convertedColumn)) { if (effective === null || effective === '') { @@ -1000,11 +1015,6 @@ export async function updateColumnType( ) } - const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c)) - const updatedColumns = renamedColumns.map((c, i) => - i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c - ) - const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) if (!columnValidation.valid) { throw new OrchestrationError( @@ -1013,7 +1023,6 @@ export async function updateColumnType( ) } - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } const now = new Date() // Cell rewrites are owned by the column-type registry, keyed by direction. @@ -1029,9 +1038,7 @@ export async function updateColumnType( resolved: new Map(), } await migrationFrom(column.type)?.(migrationContext) - if (isSelectType) { - await migrationTo(data.newType)?.(migrationContext) - } else { + if (!isSelectType || sourceNormalizesConversion) { let rewriteAfterId: string | undefined while (true) { const rows = await readColumnRetypePage( @@ -1045,7 +1052,7 @@ export async function updateColumnType( if (rows.length === 0) break const coercedByRowId = new Map() for (const row of rows) { - const rewrite = retypeCellRewrite(row.value, convertedColumn) + const rewrite = retypeCellRewrite(row.value, convertedColumn, column) if (rewrite) coercedByRowId.set(row.id, rewrite.value) } await writeBackCoercedCells( @@ -1059,6 +1066,9 @@ export async function updateColumnType( if (rows.length < retypeScanBatchSize) break } } + if (isSelectType) { + await migrationTo(data.newType)?.(migrationContext) + } // A `unique` arriving with this retype is validated HERE, against the values // the conversion just wrote — not by the separate constraint write that diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 2ad05fa5fa5..d405fbb5e43 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -481,6 +481,7 @@ export function coerceValue( options?: NormalizeDateCellOptions & { currencyCode?: string } ): string | number | boolean | null | Record | unknown[] { if (value === null || value === undefined || value === '') return null + switch (colType) { case 'number': { const n = Number(value) diff --git a/apps/sim/lib/table/schema-invariants.ts b/apps/sim/lib/table/schema-invariants.ts index 132b343dddc..ffb7407f1b5 100644 --- a/apps/sim/lib/table/schema-invariants.ts +++ b/apps/sim/lib/table/schema-invariants.ts @@ -11,6 +11,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { getColumnId } from '@/lib/table/column-keys' +import { validateColumnTypeLimits } from '@/lib/table/column-types' import type { TableSchema, WorkflowGroup } from '@/lib/table/types' /** @@ -19,7 +20,7 @@ import type { TableSchema, WorkflowGroup } from '@/lib/table/types' * etc. Returns a list of human-readable errors (empty if valid). */ export function validateSchema(schema: TableSchema, columnOrder: string[] | undefined): string[] { - const errors: string[] = [] + const errors = validateColumnTypeLimits(schema.columns) // Group refs and columnOrder hold stable column ids (not display names). const columnsById = new Map(schema.columns.map((c) => [getColumnId(c), c])) const groups = schema.workflowGroups ?? [] diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 6063ec9be9c..0f13f2a4b04 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -824,6 +824,7 @@ export async function addTableColumnsWithTx( ...table.schema, columns: [...table.schema.columns, ...additions], } + assertValidSchema(updatedSchema, table.metadata?.columnOrder) const now = new Date() await trx diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index aa9a918beb8..e5fd89c3d2d 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -14,6 +14,7 @@ import { columnTypeOf, isColumnType, TYPE_SPECIFIC_COLUMN_KEYS, + validateColumnTypeLimits, validateTypeMetadata, } from '@/lib/table/column-types' import { @@ -244,6 +245,8 @@ export function validateTableSchema(schema: TableSchema): ValidationResult { errors.push('Duplicate column names found') } + errors.push(...validateColumnTypeLimits(schema.columns)) + return { valid: errors.length === 0, errors } }