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 }
}
|