diff --git a/apps/sim/lib/table/column-types.test.ts b/apps/sim/lib/table/column-types.test.ts new file mode 100644 index 00000000000..3561e74650b --- /dev/null +++ b/apps/sim/lib/table/column-types.test.ts @@ -0,0 +1,357 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + booleanColumnType, + COLUMN_TYPE_REGISTRY, + dateColumnType, + formatColumnValue, + getColumnType, + isValidColumnValue, + isValueCompatibleWithColumnType, + jsonColumnType, + numberColumnType, + parseColumnValue, + selectColumnType, + sqlCastForColumnType, + stringColumnType, +} from '@/lib/table/column-types' +import { COLUMN_TYPES } from '@/lib/table/constants' +import type { ColumnDefinition, JsonValue } from '@/lib/table/types' + +const stringCol: ColumnDefinition = { name: 'title', type: 'string' } +const numberCol: ColumnDefinition = { name: 'price', type: 'number' } +const booleanCol: ColumnDefinition = { name: 'active', type: 'boolean' } +const dateCol: ColumnDefinition = { name: 'due', type: 'date' } +const jsonCol: ColumnDefinition = { name: 'payload', type: 'json' } + +const status: ColumnDefinition = { + name: 'status', + type: 'select', + options: [ + { id: 'opt_open', name: 'Open' }, + { id: 'opt_closed', name: 'Closed' }, + ], +} + +const tags: ColumnDefinition = { + name: 'tags', + type: 'select', + multiple: true, + options: [ + { id: 'opt_a', name: 'Alpha' }, + { id: 'opt_b', name: 'Beta' }, + ], +} + +describe('COLUMN_TYPE_REGISTRY', () => { + it('has a definition for every entry in COLUMN_TYPES', () => { + for (const type of COLUMN_TYPES) { + expect(COLUMN_TYPE_REGISTRY[type]).toBeDefined() + } + }) +}) + +describe('getColumnType', () => { + it('resolves a known type', () => { + expect(getColumnType('number')).toBe(numberColumnType) + }) + + it('returns undefined for an unrecognized type', () => { + expect(getColumnType('currency')).toBeUndefined() + }) +}) + +describe('stringColumnType', () => { + it('sqlCast: compares as text', () => { + expect(stringColumnType.sqlCast).toBeNull() + }) + + it('parse: accepts strings, stringifies numbers/booleans, rejects objects', () => { + expect(stringColumnType.parse('hello', stringCol)).toEqual({ ok: true, value: 'hello' }) + expect(stringColumnType.parse(42, stringCol)).toEqual({ ok: true, value: '42' }) + expect(stringColumnType.parse(true, stringCol)).toEqual({ ok: true, value: 'true' }) + expect(stringColumnType.parse(['a'], stringCol)).toEqual({ ok: false }) + }) + + it('isValidValue: only a string passes', () => { + expect(stringColumnType.isValidValue('hello', stringCol)).toBeNull() + expect(stringColumnType.isValidValue(42, stringCol)).toBe('title must be string, got number') + }) + + it('format: returns the string as-is', () => { + expect(stringColumnType.format('hello', stringCol)).toBe('hello') + }) + + it('isCompatible: rejects objects and arrays, accepts everything else', () => { + expect(stringColumnType.isCompatible('42', { type: 'string' })).toBe(true) + expect(stringColumnType.isCompatible(42, { type: 'string' })).toBe(true) + expect(stringColumnType.isCompatible(['a'], { type: 'string' })).toBe(false) + expect(stringColumnType.isCompatible({ a: 1 }, { type: 'string' })).toBe(false) + }) +}) + +describe('numberColumnType', () => { + it('sqlCast: numeric', () => { + expect(numberColumnType.sqlCast).toBe('numeric') + }) + + it('parse: accepts finite numbers and numeric strings', () => { + expect(numberColumnType.parse(42, numberCol)).toEqual({ ok: true, value: 42 }) + expect(numberColumnType.parse('42', numberCol)).toEqual({ ok: true, value: 42 }) + expect(numberColumnType.parse(Number.POSITIVE_INFINITY, numberCol)).toEqual({ ok: false }) + }) + + it('parse: treats a whitespace-only string as empty rather than 0', () => { + expect(numberColumnType.parse(' ', numberCol)).toEqual({ ok: false }) + }) + + it('parse: rejects non-numeric strings', () => { + expect(numberColumnType.parse('abc', numberCol)).toEqual({ ok: false }) + }) + + it('isValidValue: only a finite number passes', () => { + expect(numberColumnType.isValidValue(42, numberCol)).toBeNull() + expect(numberColumnType.isValidValue(Number.NaN, numberCol)).toBe('price must be number') + expect(numberColumnType.isValidValue('42', numberCol)).toBe('price must be number') + }) + + it('format: stringifies the number', () => { + expect(numberColumnType.format(1234.5, numberCol)).toBe('1234.5') + }) + + it('isCompatible: finite numbers and numeric strings only', () => { + expect(numberColumnType.isCompatible(42, { type: 'number' })).toBe(true) + expect(numberColumnType.isCompatible('42', { type: 'number' })).toBe(true) + expect(numberColumnType.isCompatible(' ', { type: 'number' })).toBe(false) + expect(numberColumnType.isCompatible('abc', { type: 'number' })).toBe(false) + expect(numberColumnType.isCompatible(true, { type: 'number' })).toBe(false) + }) +}) + +describe('booleanColumnType', () => { + it('sqlCast: compares as text', () => { + expect(booleanColumnType.sqlCast).toBeNull() + }) + + it('parse: accepts booleans and true/false strings, case- and whitespace-insensitively', () => { + expect(booleanColumnType.parse(true, booleanCol)).toEqual({ ok: true, value: true }) + expect(booleanColumnType.parse(' TRUE ', booleanCol)).toEqual({ ok: true, value: true }) + expect(booleanColumnType.parse('false', booleanCol)).toEqual({ ok: true, value: false }) + expect(booleanColumnType.parse('yes', booleanCol)).toEqual({ ok: false }) + }) + + it('isValidValue: only a boolean passes', () => { + expect(booleanColumnType.isValidValue(true, booleanCol)).toBeNull() + expect(booleanColumnType.isValidValue('true', booleanCol)).toBe('active must be boolean') + }) + + it('format: stringifies the boolean', () => { + expect(booleanColumnType.format(true, booleanCol)).toBe('true') + }) + + it('isCompatible: booleans, true/false/1/0 strings, and 0/1 numbers', () => { + expect(booleanColumnType.isCompatible(true, { type: 'boolean' })).toBe(true) + expect(booleanColumnType.isCompatible('1', { type: 'boolean' })).toBe(true) + expect(booleanColumnType.isCompatible(1, { type: 'boolean' })).toBe(true) + expect(booleanColumnType.isCompatible(2, { type: 'boolean' })).toBe(false) + expect(booleanColumnType.isCompatible('yes', { type: 'boolean' })).toBe(false) + }) +}) + +describe('dateColumnType', () => { + it('sqlCast: timestamptz', () => { + expect(dateColumnType.sqlCast).toBe('timestamptz') + }) + + it('parse: normalizes a calendar date and rejects unparseable strings', () => { + expect(dateColumnType.parse('2024-01-15', dateCol)).toEqual({ ok: true, value: '2024-01-15' }) + expect(dateColumnType.parse('not-a-date', dateCol)).toEqual({ ok: false }) + }) + + it('parse: accepts a Date instance and an epoch number', () => { + const result = dateColumnType.parse(new Date('2024-01-15T00:00:00Z'), dateCol) + expect(result.ok).toBe(true) + expect(dateColumnType.parse(1705276800000, dateCol).ok).toBe(true) + }) + + it('isValidValue: a Date instance or a parseable string passes', () => { + expect(dateColumnType.isValidValue('2024-01-15', dateCol)).toBeNull() + expect(dateColumnType.isValidValue(new Date('2024-01-15'), dateCol)).toBeNull() + expect(dateColumnType.isValidValue('not-a-date', dateCol)).toBe('due must be valid date') + }) + + it('format: renders a calendar date as MM/DD/YYYY', () => { + expect(dateColumnType.format('2024-01-15', dateCol)).toBe('01/15/2024') + }) + + it('isCompatible: valid Date instances and parseable strings', () => { + expect(dateColumnType.isCompatible('2024-01-15', { type: 'date' })).toBe(true) + expect(dateColumnType.isCompatible(new Date('2024-01-15'), { type: 'date' })).toBe(true) + expect(dateColumnType.isCompatible('not-a-date', { type: 'date' })).toBe(false) + expect(dateColumnType.isCompatible(42, { type: 'date' })).toBe(false) + }) +}) + +describe('jsonColumnType', () => { + it('sqlCast: compares as text', () => { + expect(jsonColumnType.sqlCast).toBeNull() + }) + + it('parse: always passes the value through unchanged', () => { + expect(jsonColumnType.parse({ a: 1 }, jsonCol)).toEqual({ ok: true, value: { a: 1 } }) + expect(jsonColumnType.parse('plain text', jsonCol)).toEqual({ ok: true, value: 'plain text' }) + }) + + it('isValidValue: anything JSON.stringify can serialize passes', () => { + expect(jsonColumnType.isValidValue({ a: 1 }, jsonCol)).toBeNull() + expect(jsonColumnType.isValidValue([1, 2, 3], jsonCol)).toBeNull() + }) + + it('isValidValue: a value JSON.stringify cannot serialize (a BigInt) fails', () => { + const unstringifiable = 10n as unknown as JsonValue + expect(jsonColumnType.isValidValue(unstringifiable, jsonCol)).toBe('payload must be valid JSON') + }) + + it('format: JSON-stringifies the value', () => { + expect(jsonColumnType.format({ a: 1 }, jsonCol)).toBe('{"a":1}') + }) + + it('isCompatible: always true', () => { + expect(jsonColumnType.isCompatible({ a: 1 }, { type: 'json' })).toBe(true) + expect(jsonColumnType.isCompatible(null, { type: 'json' })).toBe(true) + }) +}) + +describe('selectColumnType', () => { + describe('single-select', () => { + it('parse: resolves an id or a name (case-insensitively), rejects unknown values', () => { + expect(selectColumnType.parse('opt_open', status)).toEqual({ ok: true, value: 'opt_open' }) + expect(selectColumnType.parse('closed', status)).toEqual({ ok: true, value: 'opt_closed' }) + expect(selectColumnType.parse('nope', status)).toEqual({ ok: false }) + }) + + it('isValidValue: a declared option id passes, anything else fails', () => { + expect(selectColumnType.isValidValue('opt_open', status)).toBeNull() + expect(selectColumnType.isValidValue('opt_unknown', status)).toBe( + 'status must be one of the defined options' + ) + }) + + it('format: resolves the stored id to its display name', () => { + expect(selectColumnType.format('opt_open', status)).toBe('Open') + expect(selectColumnType.format(null, status)).toBe('') + }) + + it('isCompatible: an empty cell is convertible unless the target is required', () => { + expect(selectColumnType.isCompatible('', { type: 'select', options: status.options })).toBe( + true + ) + expect( + selectColumnType.isCompatible('', { + type: 'select', + options: status.options, + required: true, + }) + ).toBe(false) + }) + + it('isCompatible: a single-select target rejects multiple values', () => { + expect( + selectColumnType.isCompatible(['Open', 'Closed'], { + type: 'select', + options: status.options, + }) + ).toBe(false) + }) + }) + + describe('multi-select', () => { + it('parse: splits a comma-delimited string, resolves each part, and dedups', () => { + expect(selectColumnType.parse('Alpha, Beta, alpha', tags)).toEqual({ + ok: true, + value: ['opt_a', 'opt_b'], + }) + }) + + it('isValidValue: requires an array of declared option ids', () => { + expect(selectColumnType.isValidValue(['opt_a', 'opt_b'], tags)).toBeNull() + expect(selectColumnType.isValidValue('opt_a', tags)).toBe('tags must be a list of options') + expect(selectColumnType.isValidValue(['opt_a', 'nope'], tags)).toBe( + 'tags must only contain defined options' + ) + }) + + it('isValidValue: an empty array fails only when required', () => { + expect(selectColumnType.isValidValue([], tags)).toBeNull() + expect(selectColumnType.isValidValue([], { ...tags, required: true })).toBe( + 'Missing required field: tags' + ) + }) + + it('format: joins resolved names with a comma', () => { + expect(selectColumnType.format(['opt_b', 'opt_a'], tags)).toBe('Beta, Alpha') + }) + + it('isCompatible: every part must resolve against the target options', () => { + expect( + selectColumnType.isCompatible('Alpha, Beta', { + type: 'select', + options: tags.options, + multiple: true, + }) + ).toBe(true) + expect( + selectColumnType.isCompatible('Alpha, Gamma', { + type: 'select', + options: tags.options, + multiple: true, + }) + ).toBe(false) + }) + }) +}) + +describe('convenience wrappers', () => { + it('parseColumnValue delegates to the column type, falling back to ok:false for an unknown type', () => { + expect(parseColumnValue('42', numberCol)).toEqual({ ok: true, value: 42 }) + expect( + parseColumnValue('x', { name: 'c', type: 'currency' } as unknown as ColumnDefinition) + ).toEqual({ + ok: false, + }) + }) + + it('isValidColumnValue delegates to the column type, falling back to an error for an unknown type', () => { + expect(isValidColumnValue(42, numberCol)).toBeNull() + expect( + isValidColumnValue('x', { name: 'c', type: 'currency' } as unknown as ColumnDefinition) + ).toBe('Unknown column type "currency"') + }) + + it('formatColumnValue delegates to the column type, falling back to String(value) for an unknown type', () => { + expect(formatColumnValue('opt_open', status)).toBe('Open') + expect( + formatColumnValue(42, { name: 'c', type: 'currency' } as unknown as ColumnDefinition) + ).toBe('42') + }) + + it('isValueCompatibleWithColumnType treats null/undefined as always compatible', () => { + expect(isValueCompatibleWithColumnType(null, { type: 'number' })).toBe(true) + expect(isValueCompatibleWithColumnType(undefined, { type: 'string' })).toBe(true) + }) + + it('isValueCompatibleWithColumnType dispatches on the target type, not the source', () => { + expect(isValueCompatibleWithColumnType('Medium', { type: 'string' })).toBe(true) + expect(isValueCompatibleWithColumnType({ a: 1 }, { type: 'string' })).toBe(false) + }) + + it('sqlCastForColumnType returns the right cast per type and null for unknown/undefined', () => { + expect(sqlCastForColumnType('number')).toBe('numeric') + expect(sqlCastForColumnType('date')).toBe('timestamptz') + expect(sqlCastForColumnType('string')).toBeNull() + expect(sqlCastForColumnType('currency')).toBeNull() + expect(sqlCastForColumnType(undefined)).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/column-types.ts b/apps/sim/lib/table/column-types.ts new file mode 100644 index 00000000000..5fdbcda7cd1 --- /dev/null +++ b/apps/sim/lib/table/column-types.ts @@ -0,0 +1,306 @@ +/** + * Per-column-type behavior registry for user tables. + * + * Today the six behaviors below (SQL cast, parse, validate, format, and + * compatibility-on-conversion) are each implemented as an independent + * `switch (column.type)` in `validation.ts`, `sql.ts`, and `columns/service.ts`. + * Adding a type means finding and updating every one of them by hand, and + * missing one fails silently and differently depending which switch it was + * (see each file's `default` branch, or lack of one). This module is the + * single per-type definition those switches should eventually delegate to — + * one object per type, one `Record` covering all of `COLUMN_TYPES`, so a + * missing type is a compile error against the `Record` instead of a runtime + * surprise three files later. + * + * Not yet wired up: `cell-format.ts` and `cell-render.tsx` still carry their + * own type-specific logic, unchanged — see their own files for why. + * + * Pure module (no `@sim/db`, no Next.js) so it stays safe to import from + * client code later, the same discipline `dates.ts` documents for itself. + * That's also why `select`'s behavior delegates to `select-values.ts` (also + * pure) rather than `validation.ts` (imports `@sim/db` for its unique- + * constraint checks) — importing the latter here would taint this module or, + * once `validation.ts` migrates to depend on this one, create a cycle. + */ + +import type { COLUMN_TYPES } from '@/lib/table/constants' +import { formatDateCellDisplay, normalizeDateCellValue } from '@/lib/table/dates' +import { + resolveSelectOptionId, + selectValueToNames, + splitMultiSelectInput, +} from '@/lib/table/select-values' +import type { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types' + +export type ParseResult = { ok: true; value: T } | { ok: false } + +/** The shape of a column a value is being checked against — not necessarily a full `ColumnDefinition` (no `name`) since callers assemble it from a pending change. */ +export type TargetColumnConfig = Pick< + ColumnDefinition, + 'type' | 'options' | 'multiple' | 'required' +> + +/** + * Everything one column type needs to answer about a value: how it sorts in + * SQL, how a raw input becomes the stored primitive, whether a stored value + * is shaped correctly, how it prints, and whether it survives converting to + * (or from) another type. + */ +export interface ColumnTypeDefinition { + /** Postgres cast for JSONB text extraction when filtering/sorting, or `null` to compare as text. */ + sqlCast: 'numeric' | 'timestamptz' | null + /** Raw user/API/CSV input → the stored primitive, or `{ ok: false }` if it can't be parsed unambiguously. */ + parse(raw: JsonValue, column: ColumnDefinition): ParseResult + /** Error message if `value` (already coerced) doesn't match this type's stored shape, else `null`. */ + isValidValue(value: JsonValue, column: ColumnDefinition): string | null + /** Stored primitive → plain display string. Not the richer grid rendering (chips, links) — just text. */ + format(value: JsonValue, column: ColumnDefinition): string + /** + * Would `value` still be valid if its column became `target`? Dispatched by + * `target`'s type, not the value's current column — a type's own rules + * never depend on where a value came from. Any source-specific translation + * (e.g. a `select` id resolved to its option name) happens in the caller, + * before this is called — see `selectValueForConversion`. + */ + isCompatible(value: unknown, target: TargetColumnConfig): boolean +} + +/** Set of valid option ids for a `select`/`multiselect` column. */ +function selectOptionIds(options: SelectOption[]): Set { + return new Set(options.map((o) => o.id)) +} + +export const stringColumnType: ColumnTypeDefinition = { + sqlCast: null, + parse(raw) { + if (typeof raw === 'string') return { ok: true, value: raw } + if (typeof raw === 'number' || typeof raw === 'boolean') return { ok: true, value: String(raw) } + return { ok: false } + }, + isValidValue(value, column) { + if (typeof value !== 'string') return `${column.name} must be string, got ${typeof value}` + return null + }, + format(value) { + return typeof value === 'string' ? value : String(value) + }, + isCompatible(value) { + return typeof value !== 'object' + }, +} + +export const numberColumnType: ColumnTypeDefinition = { + sqlCast: 'numeric', + parse(raw) { + if (typeof raw === 'number') { + return Number.isFinite(raw) ? { ok: true, value: raw } : { ok: false } + } + if (typeof raw === 'string' && raw.trim() !== '') { + const parsed = Number(raw) + return Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false } + } + return { ok: false } + }, + isValidValue(value, column) { + if (typeof value !== 'number' || Number.isNaN(value)) return `${column.name} must be number` + return null + }, + format(value) { + return String(value) + }, + isCompatible(value) { + if (typeof value === 'number') return Number.isFinite(value) + if (typeof value === 'string') { + const num = Number(value) + return Number.isFinite(num) && value.trim() !== '' + } + return false + }, +} + +export const booleanColumnType: ColumnTypeDefinition = { + sqlCast: null, + parse(raw) { + if (typeof raw === 'boolean') return { ok: true, value: raw } + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase() + if (normalized === 'true') return { ok: true, value: true } + if (normalized === 'false') return { ok: true, value: false } + } + return { ok: false } + }, + isValidValue(value, column) { + if (typeof value !== 'boolean') return `${column.name} must be boolean` + return null + }, + format(value) { + return String(value) + }, + isCompatible(value) { + if (typeof value === 'boolean') return true + if (typeof value === 'string') return ['true', 'false', '1', '0'].includes(value.toLowerCase()) + if (typeof value === 'number') return value === 0 || value === 1 + return false + }, +} + +export const dateColumnType: ColumnTypeDefinition = { + sqlCast: 'timestamptz', + parse(raw) { + if (typeof raw === 'string') { + const normalized = normalizeDateCellValue(raw) + return normalized === null ? { ok: false } : { ok: true, value: normalized } + } + // Date instances and epoch numbers may still be out of the representable + // range — guard `toISOString()`, which throws on an Invalid Date. + const date = raw instanceof Date ? raw : typeof raw === 'number' ? new Date(raw) : null + if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() } + return { ok: false } + }, + isValidValue(value, column) { + if (value instanceof Date) return null + if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return null + return `${column.name} must be valid date` + }, + format(value) { + return formatDateCellDisplay(typeof value === 'string' ? value : String(value)) + }, + isCompatible(value) { + if (value instanceof Date) return !Number.isNaN(value.getTime()) + if (typeof value === 'string') return !Number.isNaN(Date.parse(value)) + return false + }, +} + +export const jsonColumnType: ColumnTypeDefinition = { + sqlCast: null, + parse(raw) { + return { ok: true, value: raw } + }, + isValidValue(value, column) { + try { + JSON.stringify(value) + return null + } catch { + return `${column.name} must be valid JSON` + } + }, + format(value) { + return JSON.stringify(value) + }, + isCompatible() { + return true + }, +} + +export const selectColumnType: ColumnTypeDefinition = { + sqlCast: null, + parse(raw, column) { + const options = column.options ?? [] + if (column.multiple) { + const parts = splitMultiSelectInput(raw) + const ids: string[] = [] + for (const part of parts) { + const id = resolveSelectOptionId(part, options) + if (id !== null && !ids.includes(id)) ids.push(id) + } + return { ok: true, value: ids } + } + // Single: tolerate an array (e.g. right after a multiple→single toggle) by + // resolving its first element so the value isn't dropped wholesale. + const single = Array.isArray(raw) ? raw[0] : raw + const id = single === undefined ? null : resolveSelectOptionId(single, options) + return id !== null ? { ok: true, value: id } : { ok: false } + }, + isValidValue(value, column) { + const ids = selectOptionIds(column.options ?? []) + if (column.multiple) { + if (!Array.isArray(value)) return `${column.name} must be a list of options` + if (!value.every((v) => typeof v === 'string' && ids.has(v))) { + return `${column.name} must only contain defined options` + } + if (column.required && value.length === 0) return `Missing required field: ${column.name}` + return null + } + if (typeof value !== 'string' || !ids.has(value)) { + return `${column.name} must be one of the defined options` + } + return null + }, + format(value, column) { + const names = selectValueToNames(column, value) + if (Array.isArray(names)) return names.join(', ') + return names ?? '' + }, + isCompatible(value, target) { + const targetOptions = target.options ?? [] + const targetMultiple = !!target.multiple + const targetRequired = !!target.required + // A cleared select cell is written as '' — still convertible, unless the + // target is required. + if (value === '') return !targetRequired + const parts = targetMultiple + ? splitMultiSelectInput(value as JsonValue) + : Array.isArray(value) + ? value + : [value] + // A single-select target can't hold several options. + if (!targetMultiple && parts.length > 1) return false + return parts.every((v) => resolveSelectOptionId(v as JsonValue, targetOptions) !== null) + }, +} + +/** + * One definition per {@link COLUMN_TYPES} entry. Typed against the literal + * union (not `Record`) so omitting or misspelling a type here is + * a compile error, not a silent gap discovered at runtime. + */ +export const COLUMN_TYPE_REGISTRY: Record<(typeof COLUMN_TYPES)[number], ColumnTypeDefinition> = { + string: stringColumnType, + number: numberColumnType, + boolean: booleanColumnType, + date: dateColumnType, + json: jsonColumnType, + select: selectColumnType, +} + +/** Looks up a column type's definition, or `undefined` for an unrecognized type string. */ +export function getColumnType(type: string): ColumnTypeDefinition | undefined { + return COLUMN_TYPE_REGISTRY[type as (typeof COLUMN_TYPES)[number]] +} + +/** Convenience wrapper mirroring `validation.ts`'s `coerceValueToColumnType` — not yet called from there. */ +export function parseColumnValue(raw: JsonValue, column: ColumnDefinition): ParseResult { + const definition = getColumnType(column.type) + return definition ? definition.parse(raw, column) : { ok: false } +} + +/** Convenience wrapper mirroring `validation.ts`'s per-case branch in `validateRowAgainstSchema` — not yet called from there. */ +export function isValidColumnValue(value: JsonValue, column: ColumnDefinition): string | null { + const definition = getColumnType(column.type) + return definition + ? definition.isValidValue(value, column) + : `Unknown column type "${column.type}"` +} + +/** Stored primitive → plain display string for `column`. New capability — no existing single call site to mirror (see the explanation of the gap this fills). */ +export function formatColumnValue(value: JsonValue, column: ColumnDefinition): string { + const definition = getColumnType(column.type) + return definition ? definition.format(value, column) : String(value) +} + +/** Convenience wrapper mirroring `columns/service.ts`'s `isValueCompatibleWithType` — not yet called from there. */ +export function isValueCompatibleWithColumnType( + value: unknown, + target: TargetColumnConfig +): boolean { + if (value === null || value === undefined) return true + const definition = getColumnType(target.type) + return definition ? definition.isCompatible(value, target) : false +} + +/** Convenience wrapper mirroring `sql.ts`'s `jsonbCastForType` — not yet called from there. */ +export function sqlCastForColumnType(type: string | undefined): 'numeric' | 'timestamptz' | null { + if (!type) return null + return getColumnType(type)?.sqlCast ?? null +} diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 4226dc935f9..fe5494925f1 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -14,6 +14,7 @@ import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, sql } from 'drizzle-orm' import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' +import { isValueCompatibleWithColumnType } from '@/lib/table/column-types' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' @@ -35,11 +36,7 @@ import type { UpdateColumnOptionsData, UpdateColumnTypeData, } from '@/lib/table/types' -import { - resolveSelectOptionId, - splitMultiSelectInput, - validateColumnDefinition, -} from '@/lib/table/validation' +import { validateColumnDefinition } from '@/lib/table/validation' import { assertValidSchema, stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableColumnService') @@ -1155,6 +1152,10 @@ export function selectValueForConversion(column: ColumnDefinition, value: unknow * * Callers converting *away* from `select` must pass the resolved option * name(s), not the stored ids — see {@link selectValueForConversion}. + * + * Delegates to the column-type registry (`column-types.ts`) so a new column + * type's conversion rule only needs to be added in one place — see + * `ColumnTypeDefinition.isCompatible`. */ export function isValueCompatibleWithType( value: unknown, @@ -1163,59 +1164,10 @@ export function isValueCompatibleWithType( targetMultiple = false, targetRequired = false ): boolean { - if (value === null || value === undefined) return true - - switch (targetType) { - case 'string': - // Arrays and objects can't become text — the write-path coercion rejects - // them and would null the cell. Multi-select values are flattened before - // this check, so anything still structured here is genuinely lossy. - return typeof value !== 'object' - case 'select': { - // A cleared select cell is written as '' — still convertible, unless the - // target is required. Required only rejects null/undefined on a write, so - // a required string column legitimately holds ''; the migration turns that - // into null (or [] for a multi), and every later update of that row would - // then fail its own required check. - if (value === '') return !targetRequired - // Read the value exactly as the write-path coercion will. A multi target - // splits a comma-delimited string, so a multiselect → text → multiselect - // round-trip (text holding this feature's own `Bug, Docs` export shape) - // stays convertible instead of being rejected as one unknown option. - const parts = targetMultiple - ? splitMultiSelectInput(value as JsonValue) - : Array.isArray(value) - ? value - : [value] - // A single-select target can't hold several options. `updateColumnOptions` - // blocks the same transition; without this the next coerce would silently - // keep only the first id. - if (!targetMultiple && parts.length > 1) return false - return parts.every((v) => resolveSelectOptionId(v as JsonValue, targetOptions) !== null) - } - case 'number': { - if (typeof value === 'number') return Number.isFinite(value) - if (typeof value === 'string') { - const num = Number(value) - return Number.isFinite(num) && value.trim() !== '' - } - return false - } - case 'boolean': { - if (typeof value === 'boolean') return true - if (typeof value === 'string') - return ['true', 'false', '1', '0'].includes(value.toLowerCase()) - if (typeof value === 'number') return value === 0 || value === 1 - return false - } - case 'date': { - if (value instanceof Date) return !Number.isNaN(value.getTime()) - if (typeof value === 'string') return !Number.isNaN(Date.parse(value)) - return false - } - case 'json': - return true - default: - return false - } + return isValueCompatibleWithColumnType(value, { + type: targetType, + options: targetOptions, + multiple: targetMultiple, + required: targetRequired, + }) } diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index 91baff2b94e..18e759994a2 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -136,6 +136,10 @@ describe('import', () => { expect(coerceValue('not a number', 'number')).toBeNull() }) + it('treats a whitespace-only number cell as empty rather than 0', () => { + expect(coerceValue(' ', 'number')).toBeNull() + }) + it('coerces booleans strictly', () => { expect(coerceValue('true', 'boolean')).toBe(true) expect(coerceValue('FALSE', 'boolean')).toBe(false) @@ -150,6 +154,10 @@ describe('import', () => { ) expect(coerceValue('not-a-date', 'date')).toBe('not-a-date') }) + + it('passes a select cell through as raw text — resolving to an option id happens later, at the schema level', () => { + expect(coerceValue('Medium', 'select')).toBe('Medium') + }) }) describe('buildAutoMapping', () => { diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 94b4dc2272d..87c61f2279e 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -13,6 +13,7 @@ import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' import { getColumnId } from '@/lib/table/column-keys' +import type { COLUMN_TYPES } from '@/lib/table/constants' import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' @@ -193,8 +194,18 @@ export async function detectCsvDelimiter( return best?.delimiter ?? fallback } -/** Narrower type than `COLUMN_TYPES` used internally for coercion. */ -export type CsvColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' +/** + * The type of the column a CSV cell is being coerced into. Same union as + * `COLUMN_TYPES` — a target column can be any type, including `select`, when + * importing into an existing table. Derived from the constant rather than + * hand-duplicated (as this alias previously was, which is how it silently + * drifted out of sync and excluded `select`) so it can't drift again. + * + * Deliberately not reused for {@link inferColumnType}'s return type — "every + * column type" and "every type we can guess from raw text" are different, + * independently-sized questions. See `InferableColumnType`. + */ +export type ColumnType = (typeof COLUMN_TYPES)[number] /** Number of CSV rows sampled when inferring column types for a new table. */ export const CSV_SCHEMA_SAMPLE_SIZE = 100 @@ -272,12 +283,22 @@ export async function parseCsvBuffer( return { headers, rows: parsed } } +/** + * Types `inferColumnType` can guess from raw CSV text. Independent of + * `ColumnType`/`COLUMN_TYPES` on purpose: `select` can never be inferred (its + * options have to be declared, not sniffed), and JSON is deliberately never + * inferred either (see below) — neither exclusion should change just because + * an unrelated column type is added elsewhere. + */ +const INFERABLE_COLUMN_TYPES = ['string', 'number', 'boolean', 'date'] as const +export type InferableColumnType = (typeof INFERABLE_COLUMN_TYPES)[number] + /** * Infers a column type from a sample of non-empty values. Order matters: we * prefer narrower types (number > boolean > ISO date) and fall back to string. * JSON is never inferred automatically. */ -export function inferColumnType(values: unknown[]): Exclude { +export function inferColumnType(values: unknown[]): InferableColumnType { const nonEmpty = values.filter((v) => v !== null && v !== undefined && v !== '') if (nonEmpty.length === 0) return 'string' @@ -363,13 +384,18 @@ export function inferSchemaFromCsv( */ export function coerceValue( value: unknown, - colType: CsvColumnType, + colType: ColumnType, options?: NormalizeDateCellOptions ): string | number | boolean | null | Record | unknown[] { if (value === null || value === undefined || value === '') return null switch (colType) { case 'number': { - const n = Number(value) + // Guard the empty/whitespace case before Number() — Number(' ') is 0, + // not NaN, so without this a blank CSV cell would silently import as 0 + // instead of null. + const trimmed = String(value).trim() + if (trimmed === '') return null + const n = Number(trimmed) return Number.isNaN(n) ? null : n } case 'boolean': { @@ -389,6 +415,12 @@ export function coerceValue( return String(value) } } + case 'select': + // A select cell's real coercion (raw text → option id) needs the + // column's declared options, which this function doesn't have — it + // passes the raw text through, and the schema-level coerceRowToSchema + // pass (which does have the column, via the registry) resolves it. + return String(value) default: return String(value) } @@ -564,7 +596,7 @@ export function coerceRowsForTable( if (!colName) continue const col = colByName.get(colName) if (!col) continue - const colType = (col.type as CsvColumnType) ?? 'string' + const colType = col.type ?? 'string' coerced[getColumnId(col)] = coerceValue(value, colType, options) as RowData[string] } return coerced diff --git a/apps/sim/lib/table/index.ts b/apps/sim/lib/table/index.ts index fad60f65370..9203f7a6da1 100644 --- a/apps/sim/lib/table/index.ts +++ b/apps/sim/lib/table/index.ts @@ -7,6 +7,7 @@ export * from '@/lib/table/billing' export * from '@/lib/table/column-keys' +export * from '@/lib/table/column-types' export * from '@/lib/table/columns/service' export * from '@/lib/table/constants' export * from '@/lib/table/dates' diff --git a/apps/sim/lib/table/select-values.test.ts b/apps/sim/lib/table/select-values.test.ts index 041b2f61242..0b39af725fe 100644 --- a/apps/sim/lib/table/select-values.test.ts +++ b/apps/sim/lib/table/select-values.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { resolveFilterSelectValues, resolvePredicateSelectValues, + resolveSelectOptionId, selectValueToNames, } from '@/lib/table/select-values' import type { ColumnDefinition } from '@/lib/table/types' @@ -59,6 +60,22 @@ describe('selectValueToNames', () => { }) }) +describe('resolveSelectOptionId', () => { + const options = status.options ?? [] + + it('resolves a stable id', () => { + expect(resolveSelectOptionId('opt_open', options)).toBe('opt_open') + }) + + it('resolves a display name (case-insensitively)', () => { + expect(resolveSelectOptionId('closed', options)).toBe('opt_closed') + }) + + it('returns null for an unknown value (drives the type-conversion compatibility gate)', () => { + expect(resolveSelectOptionId('nope', options)).toBeNull() + }) +}) + // Row-level select resolution now lives in `__tests__/cell-format.test.ts`, // fused with the column key translation. diff --git a/apps/sim/lib/table/select-values.ts b/apps/sim/lib/table/select-values.ts index c120a989ed7..de40059aa98 100644 --- a/apps/sim/lib/table/select-values.ts +++ b/apps/sim/lib/table/select-values.ts @@ -18,10 +18,53 @@ import type { JsonValue, Predicate, PredicateNode, + SelectOption, TablePredicate, TableSchema, } from '@/lib/table/types' -import { resolveSelectOptionId } from '@/lib/table/validation' + +/** + * Resolves a raw cell value to a declared option id, accepting either the + * stable id or (tolerant for tool/import writes) the option's display name. + * Returns null when no option matches. Exported so the column-type registry's + * `select` behavior (`column-types.ts`) and the type-conversion compatibility + * gate (`columns/service.ts`) share one resolution rule. + */ +export function resolveSelectOptionId(value: JsonValue, options: SelectOption[]): string | null { + // The block builder serializes without schema access, so an option NAME that + // looks numeric or boolean ("123", "true") arrives scalar-coerced. Stringify + // scalars so the name still resolves; arrays/objects stay unresolvable. + const text = + typeof value === 'string' + ? value + : typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : null + if (text === null) return null + const byId = options.find((o) => o.id === text) + if (byId) return byId.id + const byName = + options.find((o) => o.name === text) ?? + options.find((o) => o.name.toLowerCase() === text.toLowerCase()) + return byName ? byName.id : null +} + +/** + * Splits a raw value into the parts a multi-select cell should resolve. A cell + * may arrive as an array (canonical) or as a single comma-delimited string — + * the shape a multi cell exports, copies, and converts to text as — so both the + * write-path coercion and the column-conversion compatibility check read it + * through here rather than each deciding for itself. Option names that + * themselves contain commas are an accepted ambiguity. + */ +export function splitMultiSelectInput(value: JsonValue): JsonValue[] { + if (Array.isArray(value)) return value + if (typeof value !== 'string') return [value] + return value + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '') +} /** * Resolves a `select` cell's stored option id(s) to their display name(s). A diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 7ea5a6de3f0..947a4a0e059 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -9,6 +9,7 @@ import { isRecordLike } from '@sim/utils/object' import type { SQL } from 'drizzle-orm' import { sql } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' +import { sqlCastForColumnType } from '@/lib/table/column-types' import { NAME_PATTERN } from '@/lib/table/constants' import { TableQueryValidationError } from '@/lib/table/errors' import type { @@ -68,17 +69,12 @@ const MULTI_SELECT_OPS = new Set([ * Returns the Postgres cast needed to compare a JSONB text value of the given * column type, or `null` when text comparison is correct. Single source of * truth for both filter range operators and sort ordering — keeps the two - * paths from drifting apart. + * paths from drifting apart. Delegates to the column-type registry + * (`column-types.ts`) so a new column type's cast rule only needs to be added + * in one place — see `ColumnTypeDefinition.sqlCast`. */ function jsonbCastForType(type: ColumnType | undefined): 'numeric' | 'timestamptz' | null { - switch (type) { - case 'number': - return 'numeric' - case 'date': - return 'timestamptz' - default: - return null - } + return sqlCastForColumnType(type) } /** diff --git a/apps/sim/lib/table/validation.test.ts b/apps/sim/lib/table/validation.test.ts index 5e2b28732a0..a6e4eb38a22 100644 --- a/apps/sim/lib/table/validation.test.ts +++ b/apps/sim/lib/table/validation.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest' import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types' import { coerceRowToSchema, - resolveSelectOptionId, validateColumnDefinition, validateRowAgainstSchema, } from '@/lib/table/validation' @@ -123,22 +122,6 @@ describe('coerceRowToSchema — multiselect', () => { }) }) -describe('resolveSelectOptionId', () => { - const options = selectColumn.options ?? [] - - it('resolves a stable id', () => { - expect(resolveSelectOptionId('opt_open', options)).toBe('opt_open') - }) - - it('resolves a display name (case-insensitively)', () => { - expect(resolveSelectOptionId('closed', options)).toBe('opt_closed') - }) - - it('returns null for an unknown value (drives the type-conversion compatibility gate)', () => { - expect(resolveSelectOptionId('nope', options)).toBeNull() - }) -}) - describe('validateColumnDefinition — select options', () => { it('accepts a well-formed select column', () => { expect(validateColumnDefinition(selectColumn).valid).toBe(true) diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index 14275ea74eb..9c4307149a1 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -7,6 +7,7 @@ import { userTableRows } from '@sim/db/schema' import { and, eq, or, type SQL, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getColumnId } from '@/lib/table/column-keys' +import { isValidColumnValue, parseColumnValue } from '@/lib/table/column-types' import { COLUMN_TYPES, getMaxRowSizeBytes, @@ -15,14 +16,12 @@ import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME, } from '@/lib/table/constants' -import { normalizeDateCellValue } from '@/lib/table/dates' import { withSeqscanOff } from '@/lib/table/planner' import { fieldPredicate } from '@/lib/table/sql' import type { ColumnDefinition, JsonValue, RowData, - SelectOption, TableSchema, ValidationResult, } from '@/lib/table/types' @@ -220,7 +219,12 @@ export function validateTableSchema(schema: TableSchema): ValidationResult { return { valid: errors.length === 0, errors } } -/** Validates row data matches schema column types and required fields. */ +/** + * Validates row data matches schema column types and required fields. + * Delegates each column's shape check to the column-type registry + * (`column-types.ts`) so a new column type's validation rule only needs to be + * added in one place — see `ColumnTypeDefinition.isValidValue`. + */ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): ValidationResult { const errors: string[] = [] @@ -234,174 +238,26 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va if (value === null || value === undefined) continue - switch (column.type) { - case 'string': - if (typeof value !== 'string') { - errors.push(`${column.name} must be string, got ${typeof value}`) - } - break - case 'number': - if (typeof value !== 'number' || Number.isNaN(value)) { - errors.push(`${column.name} must be number`) - } - break - case 'boolean': - if (typeof value !== 'boolean') { - errors.push(`${column.name} must be boolean`) - } - break - case 'date': - if ( - !(value instanceof Date) && - (typeof value !== 'string' || Number.isNaN(Date.parse(value))) - ) { - errors.push(`${column.name} must be valid date`) - } - break - case 'json': - try { - JSON.stringify(value) - } catch { - errors.push(`${column.name} must be valid JSON`) - } - break - case 'select': { - const ids = optionIds(column) - if (column.multiple) { - if (!Array.isArray(value)) { - errors.push(`${column.name} must be a list of options`) - } else if (!value.every((v) => typeof v === 'string' && ids.has(v))) { - errors.push(`${column.name} must only contain defined options`) - } else if (column.required && value.length === 0) { - errors.push(`Missing required field: ${column.name}`) - } - } else if (typeof value !== 'string' || !ids.has(value)) { - errors.push(`${column.name} must be one of the defined options`) - } - break - } - } + const error = isValidColumnValue(value, column) + if (error) errors.push(error) } return { valid: errors.length === 0, errors } } -/** Set of valid option ids for a `select`/`multiselect` column. */ -function optionIds(column: ColumnDefinition): Set { - return new Set((column.options ?? []).map((o) => o.id)) -} - -/** - * Resolves a raw cell value to a declared option id, accepting either the - * stable id or (tolerant for tool/import writes) the option's display name. - * Returns null when no option matches. Exported so the column-type-conversion - * path can gate a `select`/`multiselect` change on whether existing values - * actually fit the target option set. - */ -export function resolveSelectOptionId(value: JsonValue, options: SelectOption[]): string | null { - // The block builder serializes without schema access, so an option NAME that - // looks numeric or boolean ("123", "true") arrives scalar-coerced. Stringify - // scalars so the name still resolves; arrays/objects stay unresolvable. - const text = - typeof value === 'string' - ? value - : typeof value === 'number' || typeof value === 'boolean' - ? String(value) - : null - if (text === null) return null - const byId = options.find((o) => o.id === text) - if (byId) return byId.id - const byName = - options.find((o) => o.name === text) ?? - options.find((o) => o.name.toLowerCase() === text.toLowerCase()) - return byName ? byName.id : null -} - -/** - * Splits a raw value into the parts a multi-select cell should resolve. A cell - * may arrive as an array (canonical) or as a single comma-delimited string — - * the shape a multi cell exports, copies, and converts to text as — so both the - * write-path coercion and the column-conversion compatibility check read it - * through here rather than each deciding for itself. Option names that - * themselves contain commas are an accepted ambiguity. - */ -export function splitMultiSelectInput(value: JsonValue): JsonValue[] { - if (Array.isArray(value)) return value - if (typeof value !== 'string') return [value] - return value - .split(',') - .map((part) => part.trim()) - .filter((part) => part !== '') -} - /** * Attempts to coerce a non-null value to a column's declared type. Returns the * coerced value when the value already matches or can be converted without * ambiguity (e.g. the string `"1999"` to the number `1999`), and `ok: false` - * when no safe conversion exists. + * when no safe conversion exists. Delegates to the column-type registry + * (`column-types.ts`) so a new column type's parse rule only needs to be + * added in one place — see `ColumnTypeDefinition.parse`. */ function coerceValueToColumnType( value: JsonValue, column: ColumnDefinition ): { ok: true; value: JsonValue } | { ok: false } { - switch (column.type) { - case 'string': - if (typeof value === 'string') return { ok: true, value } - if (typeof value === 'number' || typeof value === 'boolean') { - return { ok: true, value: String(value) } - } - return { ok: false } - case 'number': - if (typeof value === 'number') { - return Number.isFinite(value) ? { ok: true, value } : { ok: false } - } - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number(value) - return Number.isFinite(parsed) ? { ok: true, value: parsed } : { ok: false } - } - return { ok: false } - case 'boolean': - if (typeof value === 'boolean') return { ok: true, value } - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase() - if (normalized === 'true') return { ok: true, value: true } - if (normalized === 'false') return { ok: true, value: false } - } - return { ok: false } - case 'date': { - if (typeof value === 'string') { - const normalized = normalizeDateCellValue(value) - return normalized === null ? { ok: false } : { ok: true, value: normalized } - } - // Date instances and epoch numbers may still be out of the representable - // range (>±8.64e15ms) — guard `toISOString()`, which throws RangeError on - // an Invalid Date, so an over-range value degrades to `{ ok: false }` - // rather than crashing the write. - const date = - value instanceof Date ? value : typeof value === 'number' ? new Date(value) : null - if (date && !Number.isNaN(date.getTime())) return { ok: true, value: date.toISOString() } - return { ok: false } - } - case 'select': { - const options = column.options ?? [] - if (column.multiple) { - const raw = splitMultiSelectInput(value) - const ids: string[] = [] - for (const entry of raw) { - const id = resolveSelectOptionId(entry, options) - if (id !== null && !ids.includes(id)) ids.push(id) - } - return { ok: true, value: ids } - } - // Single: tolerate an array (e.g. right after a multiple→single toggle) by - // resolving its first element so the value isn't dropped wholesale. - const single = Array.isArray(value) ? value[0] : value - const id = single === undefined ? null : resolveSelectOptionId(single, options) - return id !== null ? { ok: true, value: id } : { ok: false } - } - default: - return { ok: true, value } - } + return parseColumnValue(value, column) } /** diff --git a/packages/testing/src/factories/table.factory.ts b/packages/testing/src/factories/table.factory.ts index 020787735ec..b13ba3cccca 100644 --- a/packages/testing/src/factories/table.factory.ts +++ b/packages/testing/src/factories/table.factory.ts @@ -2,7 +2,14 @@ import { generateShortId } from '@sim/utils/id' const COLUMN_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789_' -export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' +/** + * Mirrors `apps/sim/lib/table/constants.ts`'s `COLUMN_TYPES` and must be kept + * in sync with it by hand — `packages/*` can't import from `apps/*` (see + * `.claude/rules/sim-architecture.md`), so there's no single source of truth + * this can be derived from across the package boundary. This was previously + * missing `select`. + */ +export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'select' export interface TableColumnFixture { name: string