diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 90762132025..63fd7d6aef7 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -203,6 +203,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # FORKING_ENABLED= # Workspace forks # CREDENTIAL_GROUPS= # Enterprise managed OAuth collections # TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup +# TABLE_REFERENCE_COLUMNS= # Table Reference columns # KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only diff --git a/apps/sim/app/api/table/[tableId]/columns/route.test.ts b/apps/sim/app/api/table/[tableId]/columns/route.test.ts index 24830309efc..849ce8737e6 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.test.ts @@ -55,6 +55,13 @@ vi.mock('@/lib/table/wire', () => ({ vi.mock('@/app/api/table/utils', () => ({ accessError: () => new Response('denied', { status: 403 }), checkAccess: mockCheckAccess, + orchestrationErrorResponse: (error: unknown) => + error instanceof OrchestrationError + ? NextResponse.json( + { error: error.message }, + { status: statusForOrchestrationError(error.code) } + ) + : null, orchestrationOutcomeErrorResponse: ( outcome: { error?: string; errorCode?: OrchestrationErrorCode }, fallback: string @@ -73,7 +80,7 @@ import { type OrchestrationErrorCode, statusForOrchestrationError, } from '@/lib/core/orchestration/types' -import { PATCH } from '@/app/api/table/[tableId]/columns/route' +import { PATCH, POST } from '@/app/api/table/[tableId]/columns/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' @@ -88,6 +95,49 @@ function patch(updates: Record) { ) } +function post(column: Record) { + return POST( + new NextRequest('http://localhost/api/table/t1/columns', { + method: 'POST', + body: JSON.stringify({ workspaceId: WORKSPACE_ID, column }), + headers: { 'content-type': 'application/json' }, + }), + { params: Promise.resolve({ tableId: 't1' }) } + ) +} + +describe('POST /api/table/[tableId]/columns — Reference feature gate', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'session', + }) + mockCheckAccess.mockResolvedValue({ + ok: true, + table: { workspaceId: WORKSPACE_ID, schema: { columns: [] } }, + }) + }) + + it('returns 403 when Reference columns are disabled', async () => { + mockAddTableColumn.mockRejectedValue( + new OrchestrationError('forbidden', 'Reference columns are not enabled for this deployment') + ) + + const response = await post({ + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: 'Reference columns are not enabled for this deployment', + }) + }) +}) + describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index dff45ad6728..5c12f5e6245 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -17,6 +17,7 @@ import { normalizeColumn } from '@/lib/table/wire' import { accessError, checkAccess, + orchestrationErrorResponse, orchestrationOutcomeErrorResponse, rootErrorMessage, tableLockErrorResponse, @@ -69,6 +70,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum return validationErrorResponse(error, 'Invalid request data') } + const classified = orchestrationErrorResponse(error) + if (classified) return classified + const msg = rootErrorMessage(error) if ( msg.includes('already exists') || diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx new file mode 100644 index 00000000000..a73c44d303b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx @@ -0,0 +1,283 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +interface ComboboxOption { + label: string + value: string + disabled?: boolean +} + +interface ComboboxProps { + options: ComboboxOption[] + value?: string + placeholder?: string + searchable?: boolean + searchPlaceholder?: string + disabled?: boolean + onChange?: (value: string) => void +} + +interface SelectOptionsEditorProps { + options: Array<{ id: string; name: string }> + onChange: (options: Array<{ id: string; name: string }>) => void +} + +const { + capturedComboboxes, + capturedSelectEditor, + mockAddColumn, + mockUpdateColumn, + mockUseTablesList, +} = vi.hoisted(() => ({ + capturedComboboxes: { current: [] as ComboboxProps[] }, + capturedSelectEditor: { current: null as SelectOptionsEditorProps | null }, + mockAddColumn: vi.fn(), + mockUpdateColumn: vi.fn(), + mockUseTablesList: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + ChipCombobox: (props: ComboboxProps) => { + capturedComboboxes.current.push(props) + return
+ }, + ChipInput: (props: React.InputHTMLAttributes) => , + FieldDivider: () =>
, + Label: ({ children }: { children: React.ReactNode }) => {children}, + Switch: ({ checked }: { checked?: boolean }) => ( + + ), + cn: (...values: Array) => values.filter(Boolean).join(' '), + toast: { error: vi.fn(), success: vi.fn() }, +})) + +vi.mock('@sim/emcn/icons', () => ({ + PlayOutline: () => , + X: () => , +})) + +vi.mock('@/lib/table/column-types', () => ({ + ALL_COLUMN_TYPES: [ + { id: 'string', label: 'Text', icon: () => null }, + { id: 'select', label: 'Select', icon: () => null }, + { id: 'reference', label: 'Reference', icon: () => null }, + ], + columnTypeById: (type: string) => ({ supportsUnique: type !== 'select' }), +})) + +vi.mock('@/hooks/queries/tables', () => ({ + useAddTableColumn: () => ({ isPending: false, mutateAsync: mockAddColumn }), + useTablesList: mockUseTablesList, + useUpdateColumn: () => ({ isPending: false, mutateAsync: mockUpdateColumn }), +})) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({ + SelectOptionsEditor: (props: SelectOptionsEditorProps) => { + capturedSelectEditor.current = props + return
+ }, +})) + +import { ColumnConfigSidebar } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar' + +let container: HTMLDivElement +let root: Root + +function findCombobox(placeholder: string): ComboboxProps | undefined { + return capturedComboboxes.current.find((combobox) => combobox.placeholder === placeholder) +} + +function findButton(label: string): HTMLButtonElement | undefined { + return [...container.querySelectorAll('button')].find( + (button) => button.textContent === label + ) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + capturedComboboxes.current = [] + capturedSelectEditor.current = null + mockUseTablesList.mockReturnValue({ + data: [ + { id: 'table-current', name: 'Current table' }, + { id: 'table-customers', name: 'Customers' }, + ], + }) + mockAddColumn.mockResolvedValue({ data: { columns: [] } }) + mockUpdateColumn.mockResolvedValue({ data: { columns: [] } }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('ColumnConfigSidebar', () => { + it('creates a Reference column with the selected workspace table', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: true }) + expect(container.querySelector('#column-sidebar-name')?.value).toBe( + 'Related row' + ) + expect(findCombobox('Select table')).toMatchObject({ + options: [ + { label: 'Current table', value: 'table-current' }, + { label: 'Customers', value: 'table-customers' }, + ], + searchable: true, + searchPlaceholder: 'Search tables', + }) + + act(() => findCombobox('Select table')?.onChange?.('table-customers')) + await act(async () => findButton('Save')?.click()) + + expect(mockAddColumn).toHaveBeenCalledWith({ + name: 'Related row', + type: 'reference', + referenceTableId: 'table-customers', + }) + }) + + it('keeps Reference creation open until a target table is selected', async () => { + await act(async () => { + root.render( + + ) + }) + + await act(async () => findButton('Save')?.click()) + + expect(container).toHaveTextContent('Select a table') + expect(mockAddColumn).not.toHaveBeenCalled() + expect(mockUpdateColumn).not.toHaveBeenCalled() + }) + + it('edits Reference configuration without exposing column renaming', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(container).not.toHaveTextContent('Column name') + expect(container.querySelector('#column-sidebar-name')).toBeNull() + + act(() => findCombobox('Select table')?.onChange?.('table-customers')) + await act(async () => findButton('Save')?.click()) + + expect(mockUpdateColumn).toHaveBeenCalledWith({ + columnName: 'col-reference', + updates: { referenceTableId: 'table-customers' }, + }) + }) + + it('keeps an existing Reference column visible but not retargetable when disabled', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: false }) + expect(findCombobox('Select table')?.disabled).toBe(true) + expect(findCombobox('Select type')?.options).toContainEqual( + expect.objectContaining({ value: 'reference', disabled: true }) + ) + }) + + it('keeps Select options in the edit sidebar', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(container).toHaveTextContent('Options') + expect(container).toHaveTextContent('Multiselect') + act(() => + capturedSelectEditor.current?.onChange([ + { id: 'option-ready', name: 'Ready' }, + { id: 'option-done', name: 'Done' }, + ]) + ) + await act(async () => findButton('Save')?.click()) + + expect(mockUpdateColumn).toHaveBeenCalledWith({ + columnName: 'col-status', + updates: { + options: [ + { id: 'option-ready', name: 'Ready' }, + { id: 'option-done', name: 'Done' }, + ], + }, + }) + }) +}) 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 303742fdd2b..a87438c59d7 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 @@ -6,6 +6,7 @@ import { X } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' import type { ColumnDefinition, SelectOption } from '@/lib/table' +import { columnTypeById } from '@/lib/table/column-types' import { DEFAULT_CURRENCY_CODE, getCurrencyOptions, @@ -15,7 +16,7 @@ import { FieldError, RequiredLabel, } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' -import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' +import { useAddTableColumn, useTablesList, useUpdateColumn } from '@/hooks/queries/tables' import { SelectOptionsEditor } from '../select-field' import { columnTypeOptionsForTable } from './column-types' @@ -54,11 +55,9 @@ interface ColumnConfigSidebarProps { existingColumn: ColumnDefinition | null allColumns: readonly ColumnDefinition[] tableRowTtlEnabled: boolean + referenceColumnsEnabled: boolean workspaceId: string tableId: string - /** Notify parent of a rename so it can rewrite local `columnOrder` / - * `columnWidths` keys that reference the old name. */ - onColumnRename?: (oldName: string, newName: string) => void } /** @@ -106,9 +105,9 @@ function ColumnConfigBody({ existingColumn, allColumns, tableRowTtlEnabled, + referenceColumnsEnabled, workspaceId, tableId, - onColumnRename, }: ColumnConfigBodyProps) { const updateColumn = useUpdateColumn({ workspaceId, tableId }) const addColumn = useAddTableColumn({ workspaceId, tableId }) @@ -133,14 +132,30 @@ function ColumnConfigBody({ ? resolveCurrencyCode(existingColumn?.currencyCode) : DEFAULT_CURRENCY_CODE ) + const [referenceTableInput, setReferenceTableInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.referenceTableId ?? '') : '' + ) const [showValidation, setShowValidation] = useState(false) const [nameError, setNameError] = useState(null) const [optionsError, setOptionsError] = useState(null) + const [referenceTableError, setReferenceTableError] = useState(null) - const saveDisabled = updateColumn.isPending || addColumn.isPending const trimmedName = nameInput.trim() const wantsOptions = isSelectType(typeInput) const wantsCurrency = typeInput === 'currency' + const wantsReference = typeInput === 'reference' + const referenceMutationBlocked = + !referenceColumnsEnabled && + wantsReference && + (config.mode === 'create' || + existingColumn?.type !== 'reference' || + existingColumn.referenceTableId !== referenceTableInput) + const saveDisabled = updateColumn.isPending || addColumn.isPending || referenceMutationBlocked + const supportsUnique = columnTypeById(typeInput).supportsUnique + const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', { + enabled: wantsReference && referenceColumnsEnabled, + }) + const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name })) const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() })) /** Client-side option validation mirroring the server rules; returns an error message or null. */ @@ -153,8 +168,13 @@ function ColumnConfigBody({ return null } + function validateReferenceTable(): string | null { + if (!wantsReference || referenceTableInput) return null + return 'Select a table' + } + async function handleSave() { - if (!trimmedName) { + if (config.mode === 'create' && !trimmedName) { setShowValidation(true) return } @@ -164,47 +184,48 @@ function ColumnConfigBody({ setOptionsError(optionsIssue) return } + const referenceTableIssue = validateReferenceTable() + if (referenceTableIssue) { + setReferenceTableError(referenceTableIssue) + return + } try { if (config.mode === 'create') { await addColumn.mutateAsync({ name: trimmedName, type: typeInput, - // Select columns don't expose a unique constraint. - ...(!wantsOptions && uniqueInput ? { unique: true } : {}), + ...(supportsUnique && uniqueInput ? { unique: true } : {}), ...(wantsOptions ? { options: trimmedOptions } : {}), ...(wantsOptions && multipleInput ? { multiple: true } : {}), ...(wantsCurrency ? { currencyCode: currencyInput } : {}), + ...(wantsReference ? { referenceTableId: referenceTableInput } : {}), }) toast.success(`Added "${trimmedName}"`) onClose() return } - // `config.columnName` is the column id; compare against the current display - // name to detect an actual rename. - const renamed = trimmedName !== (existingColumn?.name ?? config.columnName) const typeChanged = !!existingColumn && existingColumn.type !== typeInput const uniqueChanged = - !wantsOptions && !!existingColumn && !!existingColumn.unique !== uniqueInput - // Select columns don't offer a Unique control, so converting a unique - // column to select would strand the constraint with no way to clear it. - const uniqueCleared = wantsOptions && !!existingColumn?.unique + supportsUnique && !!existingColumn && !!existingColumn.unique !== uniqueInput + const uniqueCleared = !supportsUnique && !!existingColumn?.unique const optionsChanged = wantsOptions && !optionsEqual(existingColumn?.options ?? [], trimmedOptions) const multipleChanged = wantsOptions && !!existingColumn?.multiple !== multipleInput const currencyChanged = wantsCurrency && resolveCurrencyCode(existingColumn?.currencyCode) !== currencyInput + const referenceTableChanged = + wantsReference && existingColumn?.referenceTableId !== referenceTableInput const updates: { - name?: string type?: ColumnDefinition['type'] unique?: boolean options?: SelectOption[] multiple?: boolean currencyCode?: string + referenceTableId?: string } = { - ...(renamed ? { name: trimmedName } : {}), ...(typeChanged ? { type: typeInput } : {}), ...(uniqueChanged ? { unique: uniqueInput } : {}), ...(uniqueCleared ? { unique: false } : {}), @@ -213,6 +234,9 @@ function ColumnConfigBody({ ...(wantsCurrency && (typeChanged || currencyChanged) ? { currencyCode: currencyInput } : {}), + ...(wantsReference && (typeChanged || referenceTableChanged) + ? { referenceTableId: referenceTableInput } + : {}), } if (Object.keys(updates).length === 0) { onClose() @@ -220,8 +244,7 @@ function ColumnConfigBody({ } await updateColumn.mutateAsync({ columnName: config.columnName, updates }) - if (renamed) onColumnRename?.(config.columnName, trimmedName) - toast.success(`Saved "${trimmedName}"`) + toast.success(`Saved "${existingColumn?.name ?? config.columnName}"`) onClose() } catch (err) { if (isValidationError(err)) { @@ -254,23 +277,25 @@ function ColumnConfigBody({
-
- Column name - { - setNameInput(e.target.value) - if (nameError) setNameError(null) - }} - spellCheck={false} - autoComplete='off' - error={Boolean((showValidation && !trimmedName) || nameError)} - aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} - /> - {showValidation && !trimmedName && } - {nameError && !(showValidation && !trimmedName) && } -
+ {config.mode === 'create' && ( +
+ Column name + { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + error={Boolean((showValidation && !trimmedName) || nameError)} + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} + /> + {showValidation && !trimmedName && } + {nameError && !(showValidation && !trimmedName) && } +
+ )} {config.mode === 'edit' && ( <> @@ -281,12 +306,20 @@ function ColumnConfigBody({ options={columnTypeOptionsForTable(allColumns, existingColumn, { tableRowTtlEnabled, }) - .filter((option) => option.type !== 'workflow') + .filter( + (option) => + option.type !== 'workflow' && + (referenceColumnsEnabled || + option.type !== 'reference' || + existingColumn?.type === 'reference') + ) .map((option) => ({ label: option.label, value: option.type, icon: option.icon, - disabled: option.disabledReason !== undefined, + disabled: + option.disabledReason !== undefined || + (!referenceColumnsEnabled && option.type === 'reference'), }))} value={typeInput} onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} @@ -341,8 +374,30 @@ function ColumnConfigBody({ )} - {/* Select columns don't expose a unique constraint. */} - {!wantsOptions && ( + {wantsReference && ( + <> + +
+ Table + { + setReferenceTableInput(value) + if (referenceTableError) setReferenceTableError(null) + }} + placeholder='Select table' + searchable + searchPlaceholder='Search tables' + maxHeight={260} + /> + {referenceTableError && } +
+ + )} + + {supportsUnique && ( <>
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx index e4321a7eb59..b470bef5eaa 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx @@ -33,6 +33,7 @@ describe('ColumnDropdown', () => { tableRowTtlEnabled trigger='header' disabled={false} + referenceColumnsEnabled onPickType={vi.fn()} onPickWorkflow={vi.fn()} onPickEnrichment={onPickEnrichment} @@ -57,4 +58,33 @@ describe('ColumnDropdown', () => { act(() => items.at(-1)?.click()) expect(onPickEnrichment).toHaveBeenCalledOnce() }) + + it('omits Reference when the feature is disabled', () => { + act(() => { + root.render( + + ) + }) + act(() => { + container + .querySelector('button') + ?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + }) + + const labels = [...document.body.querySelectorAll('[role="menuitem"]')].map( + (item) => item.textContent + ) + expect(labels).not.toContain('Reference') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx index 2cb10c8af94..27a54f3d568 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx @@ -27,6 +27,7 @@ interface ColumnDropdownProps { * the in-table column-header `` trigger. Same dropdown content either way. */ trigger: 'header' | 'inline-header' disabled: boolean + referenceColumnsEnabled: boolean onPickType: (type: ColumnDefinition['type']) => void onPickWorkflow: () => void onPickEnrichment: () => void @@ -84,6 +85,7 @@ export function ColumnDropdown({ tableRowTtlEnabled, trigger, disabled, + referenceColumnsEnabled, onPickType, onPickWorkflow, onPickEnrichment, @@ -126,13 +128,15 @@ export function ColumnDropdown({ {triggerButton} - {columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => { - const onSelect = - option.type === 'workflow' - ? onPickWorkflow - : () => onPickType(option.type as ColumnDefinition['type']) - return - })} + {columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }) + .filter((option) => referenceColumnsEnabled || option.type !== 'reference') + .map((option) => { + const onSelect = + option.type === 'workflow' + ? onPickWorkflow + : () => onPickType(option.type as ColumnDefinition['type']) + return + })} Enrichments diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.test.tsx new file mode 100644 index 00000000000..1527b63802d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.test.tsx @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) => + open ? <>{children} : null, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + disabled, + onSelect, + }: { + children: ReactNode + disabled?: boolean + onSelect?: () => void + }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock('@sim/emcn/icons', () => ({ + ArrowDown: () => null, + ArrowUp: () => null, + Blimp: () => null, + Duplicate: () => null, + Eye: () => null, + ListFilter: () => null, + Pencil: () => null, + PlayOutline: () => null, + RefreshCw: () => null, + Square: () => null, + Trash: () => null, +})) + +import { ContextMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function findButton(label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) +} + +describe('table row ContextMenu', () => { + it('places Copy Row Id directly below Duplicate row and invokes its handler', () => { + const onCopyRowId = vi.fn() + + act(() => { + root.render( + + ) + }) + + const labels = Array.from(container.querySelectorAll('button')).map((button) => + button.textContent?.trim() + ) + expect(labels.indexOf('Copy Row Id')).toBe(labels.indexOf('Duplicate row') + 1) + + act(() => findButton('Copy Row Id')?.click()) + expect(onCopyRowId).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 3bdc488b998..19b83568859 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -35,6 +35,8 @@ interface ContextMenuProps { onInsertAbove: () => void onInsertBelow: () => void onDuplicate: () => void + /** Copies the stable id of the row that opened the menu. Omit for an empty grid slot. */ + onCopyRowId?: () => void onViewExecution?: () => void canViewExecution?: boolean canEditCell?: boolean @@ -95,6 +97,7 @@ export function ContextMenu({ onInsertAbove, onInsertBelow, onDuplicate, + onCopyRowId, onViewExecution, canViewExecution = false, canEditCell = true, @@ -253,6 +256,12 @@ export function ContextMenu({ Duplicate row
+ {onCopyRowId && ( + + + Copy Row Id + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename.test.tsx new file mode 100644 index 00000000000..f7cc2650c50 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename.test.tsx @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + persistColumnRename, + tryStartColumnRename, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename' +import { useInlineRename } from '@/hooks/use-inline-rename' + +interface Deferred { + promise: Promise + resolve: () => void +} + +function createDeferred(): Deferred { + let resolve = () => {} + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('column rename persistence', () => { + it('does not register undo history when persistence rejects', async () => { + const error = new Error('rename rejected') + const pushUndo = vi.fn() + const onRenamed = vi.fn() + + await expect( + persistColumnRename({ + columnId: 'column-1', + oldName: 'Original', + newName: 'Updated', + persist: () => Promise.reject(error), + pushUndo, + onRenamed, + }) + ).rejects.toBe(error) + + expect(pushUndo).not.toHaveBeenCalled() + expect(onRenamed).not.toHaveBeenCalled() + }) +}) + +describe('column rename sessions', () => { + let container: HTMLDivElement + let root: Root + let rename: ReturnType + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('refuses a second session until the pending rename settles', async () => { + const deferred = createDeferred() + + function Harness() { + rename = useInlineRename({ onSave: () => deferred.promise }) + return null + } + + act(() => root.render()) + act(() => { + expect(tryStartColumnRename(rename, 'column-1', 'First')).toBe(true) + }) + act(() => rename.setEditValue('Renamed first')) + + let pendingRename: Promise + act(() => { + pendingRename = rename.submitRename() + }) + + expect(rename.isSaving).toBe(true) + act(() => { + expect(tryStartColumnRename(rename, 'column-2', 'Second')).toBe(false) + }) + expect(rename.editingId).toBe('column-1') + + await act(async () => { + deferred.resolve() + await pendingRename + }) + + expect(rename.isSaving).toBe(false) + act(() => { + expect(tryStartColumnRename(rename, 'column-2', 'Second')).toBe(true) + }) + expect(rename.editingId).toBe('column-2') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename.ts new file mode 100644 index 00000000000..6cd04c01c01 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename.ts @@ -0,0 +1,40 @@ +import type { TableUndoAction } from '@/stores/table/types' + +type RenameColumnUndoAction = Extract + +interface PersistColumnRenameOptions { + columnId: string + oldName: string + newName: string + persist: () => Promise + pushUndo: (action: RenameColumnUndoAction) => void + onRenamed: () => void +} + +export async function persistColumnRename({ + columnId, + oldName, + newName, + persist, + pushUndo, + onRenamed, +}: PersistColumnRenameOptions): Promise { + await persist() + pushUndo({ type: 'rename-column', oldName, newName, columnId }) + onRenamed() +} + +interface InlineRenameSession { + isSaving: boolean + startRename: (id: string, currentName: string) => void +} + +export function tryStartColumnRename( + session: InlineRenameSession, + columnId: string, + currentName: string +): boolean { + if (session.isSaving) return false + session.startRename(columnId, currentName) + return true +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.test.tsx new file mode 100644 index 00000000000..d0f728de929 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.test.tsx @@ -0,0 +1,188 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowGroup } from '@/lib/table' +import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), +})) + +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => null, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon', + () => ({ ColumnTypeIcon: () => null }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label', + () => ({ HeaderLabel: ({ label }: { label: string }) => {label} }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell', + () => ({ ColumnOptionsMenu: () => null }) +) + +import { ColumnHeaderMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu' + +let container: HTMLDivElement +let root: Root + +const DEFAULT_COLUMN: DisplayColumn = { + id: 'col-name', + key: 'col-name', + name: 'Name', + type: 'string', + groupSize: 1, + groupStartColIndex: 0, + headerLabel: 'Name', + isGroupStart: true, +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderHeader({ + column = DEFAULT_COLUMN, + workflowGroups, + onColumnSelect = vi.fn(), + onOpenConfig = vi.fn(), + onRenameColumn = vi.fn(), +}: { + column?: DisplayColumn + workflowGroups?: WorkflowGroup[] + onColumnSelect?: (colIndex: number, shiftKey: boolean) => void + onOpenConfig?: (columnName: string) => void + onRenameColumn?: (columnName: string) => void +} = {}) { + act(() => { + root.render( + + + + + + +
+ ) + }) + + const headerButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes(column.workflowGroupId ? column.headerLabel : column.name) + ) + if (!headerButton) throw new Error('Column header button was not rendered') + return headerButton +} + +describe('ColumnHeaderMenu interactions', () => { + it('selects the column without opening configuration on a single click', () => { + const onColumnSelect = vi.fn() + const onOpenConfig = vi.fn() + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ onColumnSelect, onOpenConfig, onRenameColumn }) + + act(() => headerButton.click()) + + expect(onColumnSelect).toHaveBeenCalledWith(2, false) + expect(onOpenConfig).not.toHaveBeenCalled() + expect(onRenameColumn).not.toHaveBeenCalled() + }) + + it('selects before starting inline rename on a double click', () => { + const onColumnSelect = vi.fn() + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ onColumnSelect, onRenameColumn }) + + act(() => { + headerButton.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })) + headerButton.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })) + headerButton.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(onColumnSelect).toHaveBeenCalledTimes(1) + expect(onRenameColumn).toHaveBeenCalledWith('col-name') + }) + + it('does not rename a workflow-output column on double click', () => { + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ + column: { ...DEFAULT_COLUMN, workflowGroupId: 'workflow-group' }, + workflowGroups: [ + { + id: 'workflow-group', + workflowId: 'workflow-1', + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col-name' }], + }, + ], + onRenameColumn, + }) + + act(() => { + headerButton.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(onRenameColumn).not.toHaveBeenCalled() + }) + + it('renames an enrichment column on double click', () => { + const onRenameColumn = vi.fn() + const headerButton = renderHeader({ + column: { ...DEFAULT_COLUMN, workflowGroupId: 'enrichment-group' }, + workflowGroups: [ + { + id: 'enrichment-group', + workflowId: '', + enrichmentId: 'company-domain', + type: 'enrichment', + outputs: [{ blockId: '', path: '', outputId: 'domain', columnName: 'col-name' }], + }, + ], + onRenameColumn, + }) + + act(() => { + headerButton.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(onRenameColumn).toHaveBeenCalledWith('col-name') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index 04025f40920..6421d7b0601 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -18,12 +18,18 @@ interface ColumnHeaderMenuProps { isRenaming: boolean isColumnSelected: boolean renameValue: string + /** Marks a refused inline rename until the user changes or cancels it. */ + renameError?: boolean onRenameValueChange: (value: string) => void onRenameSubmit: () => void onRenameCancel: () => void onColumnSelect: (colIndex: number, shiftKey: boolean) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void + /** Starts inline renaming when a plain or enrichment header is double-clicked. */ + onRenameColumn?: (columnName: string) => void + /** Opens the table targeted by a Reference column. */ + onGoToReferenceTable?: (tableId: string) => void onDeleteColumn: (columnName: string) => void onResizeStart: (columnKey: string) => void onResize: (columnKey: string, width: number) => void @@ -68,12 +74,15 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ isRenaming, isColumnSelected, renameValue, + renameError, onRenameValueChange, onRenameSubmit, onRenameCancel, onColumnSelect, onInsertLeft, onInsertRight, + onRenameColumn, + onGoToReferenceTable, onDeleteColumn, onResizeStart, onResize, @@ -115,6 +124,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ ? 'Hide column' : 'Delete column' : undefined + const isWorkflowOutput = Boolean(column.workflowGroupId && ownGroup?.type !== 'enrichment') useEffect(() => { if (isRenaming && renameInputRef.current) { renameInputRef.current.focus() @@ -224,10 +234,13 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ return } if (isRenaming) return + if (e.detail > 1) return onColumnSelect(colIndex, e.shiftKey) - if (!e.shiftKey) { - onOpenConfig(column.key) - } + } + + function handleHeaderDoubleClick() { + if (isRenaming || isWorkflowOutput) return + onRenameColumn?.(column.key) } function handleChevronClick(e: React.MouseEvent) { @@ -281,7 +294,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
@@ -295,14 +308,18 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ if (e.key === 'Escape') onRenameCancel() }} onBlur={onRenameSubmit} - className='ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-primary)] text-small outline-none focus:outline-none focus:ring-0' + aria-invalid={renameError || undefined} + className={cn( + 'ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 text-small outline-none focus:outline-none focus:ring-0', + renameError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]' + )} />
) : readOnly ? (
@@ -317,11 +334,12 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ type='button' className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none' onClick={handleHeaderClick} + onDoubleClick={handleHeaderDoubleClick} draggable={false} > @@ -346,6 +364,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column={column} deleteLabel={deleteLabel} onOpenConfig={onOpenConfig} + onGoToReferenceTable={onGoToReferenceTable} onInsertLeft={onInsertLeft} onInsertRight={onInsertRight} onDeleteColumn={onDeleteColumn} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx new file mode 100644 index 00000000000..6bb57a09e60 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.test.tsx @@ -0,0 +1,144 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ColumnDefinition } from '@/lib/table' + +vi.mock('@sim/emcn', () => ({ + cn: (...values: Array) => values.filter(Boolean).join(' '), + DropdownMenu: ({ children, open }: { children: ReactNode; open: boolean }) => + open ? <>{children} : null, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => {children}, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock('@sim/emcn/icons', () => ({ + ArrowDown: () => null, + ArrowLeft: () => null, + ArrowRight: () => null, + ArrowUp: () => null, + Eye: () => null, + EyeOff: () => null, + Fingerprint: () => null, + Pencil: () => null, + Pin: () => null, + PinOff: () => null, + PlayOutline: () => null, + SquareArrowUpRight: () => null, + Trash: () => null, + Workflow: () => null, + X: () => null, +})) + +vi.mock('@/lib/table/column-types', () => ({ + columnTypeOf: (column: ColumnDefinition) => ({ + icon: () => null, + label: column.type === 'reference' ? 'Reference' : 'Text', + }), +})) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar', () => ({ + PLAIN_COLUMN_TYPE_OPTIONS: [], +})) + +vi.mock('@/enrichments/registry', () => ({ getEnrichment: () => undefined })) + +import { ColumnOptionsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderMenu(column: ColumnDefinition, onGoToReferenceTable: (tableId: string) => void) { + act(() => { + root.render( + + ) + }) +} + +function findButton(label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) +} + +describe('ColumnOptionsMenu Reference navigation', () => { + it('opens the table targeted by a Reference column', () => { + const onGoToReferenceTable = vi.fn() + renderMenu( + { + id: 'col-account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + }, + onGoToReferenceTable + ) + + act(() => findButton('Go to Reference Table')?.click()) + + expect(onGoToReferenceTable).toHaveBeenCalledWith('table-accounts') + }) + + it('does not show the action for a non-Reference column', () => { + renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn()) + + expect(findButton('Go to Reference Table')).toBeUndefined() + }) + + it('does not show the action when Reference metadata has no target table', () => { + renderMenu({ id: 'col-account', name: 'Account', type: 'reference' }, vi.fn()) + + expect(findButton('Go to Reference Table')).toBeUndefined() + }) +}) + +describe('ColumnOptionsMenu editing', () => { + it('keeps rename out of the column menu', () => { + renderMenu({ id: 'col-name', name: 'Name', type: 'string' }, vi.fn()) + + expect(findButton('Rename column')).toBeUndefined() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index e9f4e435e11..21f1d172bd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -24,6 +24,7 @@ import { Pin, PinOff, PlayOutline, + SquareArrowUpRight, Trash, Workflow, X, @@ -70,6 +71,8 @@ interface ColumnOptionsMenuProps { * it leaves the group with siblings). */ deleteLabel?: string onOpenConfig: (columnName: string) => void + /** Opens the table targeted by a Reference column. */ + onGoToReferenceTable?: (tableId: string) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void onDeleteColumn: (columnName: string) => void @@ -111,9 +114,9 @@ interface ColumnOptionsMenuProps { /** * Shared column-options dropdown rendered next to the column header chevron * AND on right-click of the workflow group meta cell. Anchors to a fixed - * position passed in (so callers can place it under the chevron, or at the - * cursor for context-menu use). Rename / change type / unique live in the - * column sidebar (opened by Edit column). + * position passed in so callers can place it under the chevron or at the + * cursor. Rename starts in the header; type, uniqueness, and type-specific + * configuration live in the sidebar opened by Edit column. */ export function ColumnOptionsMenu({ open, @@ -122,6 +125,7 @@ export function ColumnOptionsMenu({ column, deleteLabel, onOpenConfig, + onGoToReferenceTable, onInsertLeft, onInsertRight, onDeleteColumn, @@ -142,6 +146,7 @@ export function ColumnOptionsMenu({ const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 const runLabels = runMenuLabels(hasActiveFilter) + const referenceTableId = column.type === 'reference' ? column.referenceTableId : undefined return ( @@ -228,6 +233,12 @@ export function ColumnOptionsMenu({ View workflow )} + {referenceTableId && onGoToReferenceTable && ( + onGoToReferenceTable(referenceTableId)}> + + Go to Reference Table + + )} onOpenConfig(column.key)}> Edit column 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 43f7c958bc8..f373eb48be9 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 @@ -2,15 +2,16 @@ import type React from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { cn, toast, useToast } from '@sim/emcn' +import { cn, toast, useToast, writeTextToClipboard } from '@sim/emcn' import { Loader, TableX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import type { TableCellSelection } from '@sim/realtime-protocol/table-presence' import { getErrorMessage } from '@sim/utils/errors' import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import { useVirtualizer } from '@tanstack/react-virtual' -import { useParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { extractValidationIssues, isValidationError } from '@/lib/api/client/errors' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' import { captureEvent } from '@/lib/posthog/client' @@ -31,6 +32,10 @@ import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { FindBar } from '@/app/workspace/[workspaceId]/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + persistColumnRename, + tryStartColumnRename, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/column-rename' import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' @@ -78,6 +83,7 @@ import { chipRowCount, classifyExecStatusMix, collectRowSnapshots, + columnNameIssue, computeNormalizedSelection, drainTargetForChip, type ExecStatusMix, @@ -172,6 +178,7 @@ export interface SelectionSnapshot { interface TableGridProps { workspaceId?: string tableId?: string + referenceColumnsEnabled: boolean embedded?: boolean tableRowTtlEnabled: boolean /** Remote collaborators' cell selections, rendered as presence overlays. */ @@ -436,6 +443,7 @@ async function chunkBatchUpdates( export function TableGrid({ workspaceId: propWorkspaceId, tableId: propTableId, + referenceColumnsEnabled, embedded, tableRowTtlEnabled, remoteSelections, @@ -478,6 +486,7 @@ export function TableGrid({ const params = useParams() const workspaceId = propWorkspaceId || (params.workspaceId as string) const tableId = propTableId || (params.tableId as string) + const router = useRouter() const workspaceIdRef = useRef(workspaceId) workspaceIdRef.current = workspaceId const tableIdRef = useRef(tableId) @@ -1501,16 +1510,66 @@ export function TableGrid({ const handleFindCloseRef = useRef(handleFindClose) handleFindCloseRef.current = handleFindClose + const [renameErrorColumnId, setRenameErrorColumnId] = useState(null) + const columnRename = useInlineRename({ // `columnName` is the column id; record the prior display name + id so undo // restores the label (not the id) and targets the right column. - onSave: (columnName, newName) => { + onSave: async (columnName, newName) => { const oldName = columnsRef.current.find((c) => c.key === columnName)?.name ?? columnName - pushUndoRef.current({ type: 'rename-column', oldName, newName, columnId: columnName }) - handleColumnRename(columnName, newName) - return updateColumnMutation.mutateAsync({ columnName, updates: { name: newName } }) + try { + await persistColumnRename({ + columnId: columnName, + oldName, + newName, + persist: () => + updateColumnMutation.mutateAsync({ columnName, updates: { name: newName } }), + pushUndo: pushUndoRef.current, + onRenamed: () => handleColumnRename(columnName, newName), + }) + } catch (error) { + if (isValidationError(error)) { + toast.error(extractValidationIssues(error)[0]?.message ?? getErrorMessage(error)) + } + setRenameErrorColumnId(columnName) + throw error + } }, }) + const columnRenameRef = useRef>(columnRename) + columnRenameRef.current = columnRename + + const handleRenameValueChange = useCallback((value: string) => { + setRenameErrorColumnId(null) + columnRenameRef.current.setEditValue(value) + }, []) + + /** Keeps invalid names in the header so the user can correct them in place. */ + const handleRenameSubmit = useCallback(() => { + const { editingId, editValue, submitRename } = columnRenameRef.current + const trimmedName = editValue.trim() + const currentColumn = columnsRef.current.find((column) => column.key === editingId) + if (currentColumn && trimmedName !== currentColumn.name) { + const issue = columnNameIssue( + trimmedName, + schemaColumnsRef.current + .filter((column) => getColumnId(column) !== editingId) + .map((column) => column.name) + ) + if (issue) { + toast.error(issue) + setRenameErrorColumnId(editingId) + return + } + } + setRenameErrorColumnId(null) + void submitRename() + }, []) + + const handleRenameCancel = useCallback(() => { + setRenameErrorColumnId(null) + columnRenameRef.current.cancelRename() + }, []) const toggleBooleanCell = useCallback( (rowId: string, columnName: string, currentValue: unknown) => { @@ -1719,6 +1778,19 @@ export function TableGrid({ ) } + function handleCopyRowId() { + const rowId = contextMenu.row?.id + if (!rowId) return + void writeTextToClipboard(rowId).catch(() => {}) + } + + const handleGoToReferenceTable = useCallback( + (referenceTableId: string) => { + router.push(`/workspace/${workspaceId}/tables/${referenceTableId}`) + }, + [router, workspaceId] + ) + const handleAppendRow = useCallback(async () => { if (isAppendingRowRef.current) return isAppendingRowRef.current = true @@ -3973,6 +4045,14 @@ export function TableGrid({ [onOpenColumnConfig, onOpenWorkflowConfig, workflowGroupById] ) + const handleRenameColumn = useCallback((columnName: string) => { + const column = columnsRef.current.find((candidate) => candidate.key === columnName) + if (!tryStartColumnRename(columnRenameRef.current, columnName, column?.name ?? columnName)) { + return + } + setRenameErrorColumnId(null) + }, []) + const handleConfigureWorkflowGroup = useCallback( (groupId: string) => { const group = workflowGroupById.get(groupId) @@ -4115,6 +4195,9 @@ export function TableGrid({ ...(entry.def?.options ? { columnOptions: entry.def.options } : {}), ...(entry.def?.multiple ? { columnMultiple: true } : {}), ...(entry.def?.currencyCode ? { columnCurrencyCode: entry.def.currencyCode } : {}), + ...(entry.def?.referenceTableId + ? { columnReferenceTableId: entry.def.referenceTableId } + : {}), cellData, previousOrder: orderSnapshot, previousWidth, @@ -4860,9 +4943,10 @@ export function TableGrid({ renameValue={ columnRename.editingId === column.key ? columnRename.editValue : '' } - onRenameValueChange={columnRename.setEditValue} - onRenameSubmit={columnRename.submitRename} - onRenameCancel={columnRename.cancelRename} + renameError={renameErrorColumnId === column.key} + onRenameValueChange={handleRenameValueChange} + onRenameSubmit={handleRenameSubmit} + onRenameCancel={handleRenameCancel} onColumnSelect={handleColumnSelect} // Required props here, and the menu is already // suppressed for non-editors by `readOnly`. @@ -4887,6 +4971,12 @@ export function TableGrid({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} + onRenameColumn={ + userPermissions.canEdit ? handleRenameColumn : undefined + } + onGoToReferenceTable={ + referenceColumnsEnabled ? handleGoToReferenceTable : undefined + } onViewWorkflow={handleViewWorkflow} onSortColumn={onSortColumn} onClearSort={onClearSort} @@ -4906,6 +4996,7 @@ export function TableGrid({ tableRowTtlEnabled={tableRowTtlEnabled} trigger='inline-header' disabled={addColumnMutation.isPending} + referenceColumnsEnabled={referenceColumnsEnabled} blocked={!canMutateSchema} onBlocked={() => onBlockedAction('add-column')} onPickType={handleAddColumnOfType} @@ -5064,6 +5155,7 @@ export function TableGrid({ onInsertAbove={handleInsertRowAbove} onInsertBelow={handleInsertRowBelow} onDuplicate={handleDuplicateRow} + onCopyRowId={contextMenu.row ? handleCopyRowId : undefined} onViewExecution={handleViewExecution} canViewExecution={ (Boolean(contextMenuExecutionId) && contextMenuHasStartedRun) || diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts index 80534939ca4..fb4f0ac5778 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts @@ -12,6 +12,7 @@ import { buildTableSelectionContext, canWriteRowsWithChip, chipRowCount, + columnNameIssue, drainTargetForChip, horizontalEdgeScrollVelocity, selectedColumnIds, @@ -197,3 +198,27 @@ describe('drainTargetForChip', () => { expect(drainTargetForChip(0)).toBe(MAX_TABLE_SELECTION_ROWS) }) }) + +describe('columnNameIssue', () => { + it('accepts a pattern-safe, unused name', () => { + expect(columnNameIssue('email_address', ['name', 'status'])).toBeNull() + }) + + it('requires a name', () => { + expect(columnNameIssue('', [])).toBe('Column name is required') + }) + + it('refuses invalid patterns and names that begin with a digit', () => { + expect(columnNameIssue('New Text', [])).toMatch(/letter or underscore/) + expect(columnNameIssue('1st', [])).toMatch(/letter or underscore/) + }) + + it('refuses a name longer than the column-name limit', () => { + const longName = 'a'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH + 1) + expect(columnNameIssue(longName, [])).toMatch(/characters or less/) + }) + + it('refuses an existing name case-insensitively', () => { + expect(columnNameIssue('EMAIL', ['email'])).toBe('A column named "email" already exists') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index 4f3e9282d17..d17a3add399 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -12,7 +12,7 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' -import { TABLE_LIMITS } from '@/lib/table/constants' +import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps' import type { ChatContext } from '@/stores/panel' import type { DeletedRowSnapshot } from '@/stores/table/types' @@ -486,3 +486,26 @@ export function canWriteRowsWithChip(opts: { if (!opts.hasContext || !opts.complete) return false return opts.rowCount > 0 && opts.rowCount <= TABLE_LIMITS.MAX_COPY_ROWS } + +/** + * Returns a user-facing reason that a proposed column name cannot be saved, + * or `null` when the name is valid and unused. + * + * @param takenNames Names of every other column in the table. + */ +export function columnNameIssue(name: string, takenNames: Iterable): string | null { + if (!name) return 'Column name is required' + if (name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + return `Column names must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less` + } + if (!NAME_PATTERN.test(name)) { + return 'Column names must start with a letter or underscore and use only letters, numbers, and underscores' + } + const lowerName = name.toLowerCase() + for (const takenName of takenNames) { + if (takenName.toLowerCase() === lowerName) { + return `A column named "${takenName}" already exists` + } + } + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index d39a6f474d0..4a3f8295128 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -40,6 +40,7 @@ import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presen import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getTableViewRevision, @@ -194,6 +195,8 @@ export function Table({ const router = useRouter() const workspaceId = propWorkspaceId || (params.workspaceId as string) const tableId = propTableId || (params.tableId as string) + const hostContext = useOptionalWorkspaceHostContext() + const referenceColumnsEnabled = hostContext?.features?.referenceColumns ?? false const posthog = usePostHog() const tableRowTtlEnabled = useFeatureFlag('table-row-ttl') @@ -313,9 +316,9 @@ export function Table({ }, []) /** - * Sink populated by the grid: invoked from sidebar `onColumnRename` so the - * grid can rewrite its local `columnWidths` / `columnOrder` keys after a - * rename. The grid's render assigns to `current`; the wrapper forwards calls. + * Sink populated by the grid: invoked from the workflow sidebar after a + * rename so the grid can rewrite its local `columnWidths` / `columnOrder` + * keys. The grid's render assigns to `current`; the wrapper forwards calls. */ const columnRenameSinkRef = useRef<((oldName: string, newName: string) => void) | null>(null) const onColumnRename = (oldName: string, newName: string) => { @@ -1379,6 +1382,7 @@ export function Table({ tableRowTtlEnabled={tableRowTtlEnabled} trigger='header' disabled={false} + referenceColumnsEnabled={referenceColumnsEnabled} blocked={!canMutateSchema} onBlocked={() => showBlockedToast('add-column')} onPickType={handleAddColumnOfType} @@ -1542,6 +1546,7 @@ export function Table({ { expect(payload.id).toBe('col_status') }) }) + +describe('useTableUndo – restoring a deleted currency column', () => { + it('re-creates the column with its original denomination', async () => { + mockPopUndo.mockReturnValueOnce( + makeEntry({ + type: 'delete-column', + columnName: 'amount', + columnId: 'col_amount', + columnType: 'currency', + columnPosition: 0, + columnUnique: false, + columnRequired: false, + columnCurrencyCode: 'JPY', + cellData: [], + previousOrder: null, + previousWidth: null, + previousPinnedColumns: null, + }) + ) + + const { undo } = TestHook() + ;(undo as () => void)() + await flush() + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'col_amount', + name: 'amount', + type: 'currency', + currencyCode: 'JPY', + }), + expect.any(Object) + ) + }) +}) + +describe('useTableUndo – restoring a deleted reference column', () => { + it('re-creates the column with its target table', async () => { + mockPopUndo.mockReturnValueOnce( + makeEntry({ + type: 'delete-column', + columnName: 'owner', + columnId: 'col_owner', + columnType: 'reference', + columnPosition: 0, + columnUnique: false, + columnRequired: false, + columnReferenceTableId: 'tbl_people', + cellData: [], + previousOrder: null, + previousWidth: null, + previousPinnedColumns: null, + }) + ) + + const { undo } = TestHook() + ;(undo as () => void)() + await flush() + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'col_owner', + name: 'owner', + type: 'reference', + referenceTableId: 'tbl_people', + }), + expect.any(Object) + ) + }) +}) diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 205e52b8b53..dc3bf6d96aa 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -391,6 +391,9 @@ export function useTableUndo({ ...(action.columnOptions ? { options: action.columnOptions } : {}), ...(action.columnMultiple ? { multiple: true } : {}), ...(action.columnCurrencyCode ? { currencyCode: action.columnCurrencyCode } : {}), + ...(action.columnReferenceTableId + ? { referenceTableId: action.columnReferenceTableId } + : {}), position: action.columnPosition, }, { diff --git a/apps/sim/lib/api/contracts/tables.test.ts b/apps/sim/lib/api/contracts/tables.test.ts index 7bdba27d81d..36d13bce2e6 100644 --- a/apps/sim/lib/api/contracts/tables.test.ts +++ b/apps/sim/lib/api/contracts/tables.test.ts @@ -2,7 +2,89 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { tableEventStreamQuerySchema, tableRowsQuerySchema } from '@/lib/api/contracts/tables' +import { + createTableColumnBodySchema, + tableColumnSchema, + tableEventStreamQuerySchema, + tableRowsQuerySchema, + updateTableColumnBodySchema, +} from '@/lib/api/contracts/tables' +import { MAX_REFERENCE_TABLE_ID_LENGTH } from '@/lib/table/constants' + +describe('reference column metadata', () => { + const referenceColumn = { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + } + + it('preserves the target table id in every HTTP column schema', () => { + expect(tableColumnSchema.parse(referenceColumn).referenceTableId).toBe('tbl_accounts') + expect( + createTableColumnBodySchema.parse({ + workspaceId: 'ws-1', + column: referenceColumn, + }).column.referenceTableId + ).toBe('tbl_accounts') + expect( + updateTableColumnBodySchema.parse({ + workspaceId: 'ws-1', + columnName: 'account', + updates: { referenceTableId: 'tbl_other' }, + }).updates.referenceTableId + ).toBe('tbl_other') + }) + + it('requires a non-empty target for reference columns', () => { + expect(tableColumnSchema.safeParse({ name: 'account', type: 'reference' }).success).toBe(false) + expect(tableColumnSchema.safeParse({ ...referenceColumn, referenceTableId: '' }).success).toBe( + false + ) + }) + + it('rejects reference metadata on another column type', () => { + expect(tableColumnSchema.safeParse({ ...referenceColumn, type: 'string' }).success).toBe(false) + }) + + it('bounds reference table IDs at the standard identifier length', () => { + const maximumId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH) + const oversizedId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH + 1) + + expect( + tableColumnSchema.safeParse({ ...referenceColumn, referenceTableId: maximumId }).success + ).toBe(true) + expect( + createTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + column: { ...referenceColumn, referenceTableId: maximumId }, + }).success + ).toBe(true) + expect( + updateTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + columnName: 'account', + updates: { referenceTableId: maximumId }, + }).success + ).toBe(true) + + expect( + tableColumnSchema.safeParse({ ...referenceColumn, referenceTableId: oversizedId }).success + ).toBe(false) + expect( + createTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + column: { ...referenceColumn, referenceTableId: oversizedId }, + }).success + ).toBe(false) + expect( + updateTableColumnBodySchema.safeParse({ + workspaceId: 'ws-1', + columnName: 'account', + updates: { referenceTableId: oversizedId }, + }).success + ).toBe(false) + }) +}) /** * `requestJson` parses the query through this schema on the CLIENT before building the URL, so diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 30dd03797e3..4c95b3d6520 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -31,6 +31,7 @@ import type { import { COLUMN_TYPES, FILTER_OPS, + MAX_REFERENCE_TABLE_ID_LENGTH, MAX_RUN_TARGET_ROW_IDS, MAX_SELECT_OPTIONS, MAX_TABLE_BATCH_ITEMS, @@ -55,7 +56,7 @@ export const domainObjectSchema = () => z.custom(isRecordLike) */ export const columnTypeSchema = z .enum(COLUMN_TYPES) - .meta({ omitEnumValuesFromOpenApi: ['ttl'] as const }) + .meta({ omitEnumValuesFromOpenApi: ['ttl', 'reference'] as const }) /** One choice in a `select` column. `id` is the stable cell key. */ export const selectOptionSchema = z.object({ @@ -85,18 +86,24 @@ export const currencyCodeSchema = z .regex(/^[A-Za-z]{3}$/, 'Must be a 3-letter ISO 4217 currency code, e.g. USD') .overwrite((code) => code.toUpperCase()) +export const referenceTableIdSchema = requiredFieldSchema('Reference table ID is required').max( + MAX_REFERENCE_TABLE_ID_LENGTH, + `Reference table ID must be ${MAX_REFERENCE_TABLE_ID_LENGTH} characters or less` +) + /** - * Cross-field rule: a `select` column must declare a non-empty option set; - * other types must not carry options or `multiple`, and only a `currency` - * column may carry `currencyCode`. Skipped when `type` is absent (a - * metadata-only update on an existing column). + * Cross-field rules for type-owned metadata. A `select` column must declare a + * non-empty option set, a `reference` column must declare its target table, + * and type-specific fields are rejected on every type that does not own them. + * Skipped when `type` is absent (a metadata-only update on an existing column). */ -export function refineColumnOptions( +export function refineColumnTypeMetadata( data: { type?: (typeof COLUMN_TYPES)[number] options?: z.infer multiple?: boolean currencyCode?: string + referenceTableId?: string }, ctx: z.RefinementCtx ): void { @@ -110,6 +117,20 @@ export function refineColumnOptions( message: 'currencyCode is only allowed on currency columns', }) } + if (data.type !== undefined && data.type !== 'reference' && data.referenceTableId !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['referenceTableId'], + message: 'referenceTableId is only allowed on reference columns', + }) + } + if (data.type === 'reference' && data.referenceTableId === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['referenceTableId'], + message: 'A reference column must define a reference table ID', + }) + } if (data.type === 'select') { if (!data.options || data.options.length === 0) { ctx.addIssue({ @@ -222,9 +243,13 @@ export const tableColumnSchema = z currencyCode: currencyCodeSchema .optional() .describe('ISO 4217 code for a currency column, normalized to uppercase.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Target table whose row IDs are stored by a reference column.'), }) - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) .describe('A typed column in a table schema.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }) export const createTableBodySchema = z.object({ name: tableNameSchema.describe('Table name.'), @@ -304,9 +329,13 @@ export const createTableColumnBodySchema = z.object({ options: selectOptionsSchema.optional().describe('Options for a select column.'), multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'), currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for a currency column.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Target table for a reference column.'), }) - .superRefine(refineColumnOptions) - .describe('Typed column definition to add.'), + .superRefine(refineColumnTypeMetadata) + .describe('Typed column definition to add.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) export const updateTableColumnBodySchema = z.object({ @@ -321,9 +350,13 @@ export const updateTableColumnBodySchema = z.object({ options: selectOptionsSchema.optional().describe('Replacement select options.'), multiple: z.boolean().optional().describe('New multi-select setting.'), currencyCode: currencyCodeSchema.optional().describe('New ISO 4217 currency code.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('New target table for a reference column.'), }) - .superRefine(refineColumnOptions) - .describe('Column fields to update.'), + .superRefine(refineColumnTypeMetadata) + .describe('Column fields to update.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) export const deleteTableColumnBodySchema = z.object({ diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index fa7ea3725d1..2314527a884 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -45,6 +45,37 @@ import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' describe('v2 table column contracts', () => { + it('preserves reference table metadata on every public column write', () => { + expect( + v2CreateTableBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + name: 'contacts', + schema: { + columns: [{ name: 'account', type: 'reference', referenceTableId: 'tbl_accounts' }], + }, + }) + ).toMatchObject({ + success: true, + data: { schema: { columns: [{ referenceTableId: 'tbl_accounts' }] } }, + }) + expect( + v2CreateTableColumnBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + column: { name: 'account', type: 'reference', referenceTableId: 'tbl_accounts' }, + }) + ).toMatchObject({ + success: true, + data: { column: { referenceTableId: 'tbl_accounts' } }, + }) + expect( + v2UpdateTableColumnBodySchema.safeParse({ + workspaceId: WORKSPACE_ID, + columnName: 'account', + updates: { referenceTableId: 'tbl_other' }, + }) + ).toMatchObject({ success: true, data: { updates: { referenceTableId: 'tbl_other' } } }) + }) + it('accepts required on every public column write so a column round-trips', () => { expect( v2CreateTableBodySchema.safeParse({ diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 95f1686b33a..4816f9e5725 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -17,8 +17,9 @@ import { insertTableRowBodyBaseSchema, predicateInputSchema, predicateSchema, + referenceTableIdSchema, refineCancelTableRunsScope, - refineColumnOptions, + refineColumnTypeMetadata, rowAnchorMutexRefine, runColumnBodyBaseSchema, runColumnExcludeMutexRefine, @@ -472,6 +473,9 @@ const v2TableColumnInputShape = { options: selectOptionsSchema.optional().describe('Select options for select-type columns.'), multiple: z.boolean().optional().describe('Whether a select column accepts multiple values.'), currencyCode: currencyCodeSchema.optional().describe('ISO 4217 code for currency columns.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Target table for reference columns.'), } /** @@ -489,7 +493,8 @@ const v2TableColumnInputShape = { export const v2TableColumnInputSchema = z .object(v2TableColumnInputShape) .strict() - .superRefine(refineColumnOptions) + .superRefine(refineColumnTypeMetadata) + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }) /** * Initial columns take the same shape as every other v2 column input. @@ -740,8 +745,9 @@ export const v2CreateTableColumnBodySchema = z .describe('Zero-based insertion position for the column.'), }) .strict() - .superRefine(refineColumnOptions) - .describe('Column definition to add.'), + .superRefine(refineColumnTypeMetadata) + .describe('Column definition to add.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) .strict() @@ -770,10 +776,14 @@ export const v2UpdateTableColumnBodySchema = z currencyCode: currencyCodeSchema .optional() .describe('Replacement ISO 4217 code for a currency column.'), + referenceTableId: referenceTableIdSchema + .optional() + .describe('Replacement target table for a reference column.'), }) .strict() - .superRefine(refineColumnOptions) - .describe('Mutable column fields.'), + .superRefine(refineColumnTypeMetadata) + .describe('Mutable column fields.') + .meta({ omitPropertiesFromOpenApi: ['referenceTableId'] as const }), }) .strict() diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 883c9375c0c..3875455c693 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -264,6 +264,8 @@ export const workspaceHostContextSchema = z.object({ credentialGroups: z.boolean(), /** Optional for rolling compatibility with app versions that predate the flag. */ knowledgeMemberAccess: z.boolean().optional(), + /** Optional for rolling compatibility with app versions that predate the Reference gate. */ + referenceColumns: z.boolean().optional(), }) .optional(), }) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 1e96164e1b5..3fd719ffc57 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -7,8 +7,10 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { TableDefinition } from '@/lib/table' const { + mockAddTableColumn, mockUpdateColumnType, mockUpdateColumnOptions, + mockUpdateColumnReference, mockResolveWorkspaceFileReference, mockGetBoundWorkspaceFileSecretProvenance, mockDownloadWorkspaceFile, @@ -37,8 +39,10 @@ const { mockResolveWorkflowContext, fakeEnrichment, } = vi.hoisted(() => ({ + mockAddTableColumn: vi.fn(), mockUpdateColumnType: vi.fn(), mockUpdateColumnOptions: vi.fn(), + mockUpdateColumnReference: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), mockDownloadWorkspaceFile: vi.fn(), @@ -197,11 +201,13 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ })) vi.mock('@/lib/table/columns/service', () => ({ - addTableColumn: vi.fn(), + addTableColumn: mockAddTableColumn, deleteColumn: vi.fn(), deleteColumns: mockDeleteColumns, renameColumn: vi.fn(), updateColumnConstraints: vi.fn(), + updateColumnCurrency: vi.fn(), + updateColumnReference: mockUpdateColumnReference, updateColumnType: mockUpdateColumnType, updateColumnOptions: mockUpdateColumnOptions, })) @@ -1682,6 +1688,94 @@ describe('userTableServerTool.update_rows_by_filter', () => { }) }) +describe('userTableServerTool reference column metadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + mockAddTableColumn.mockImplementation( + async (_tableId: string, column: TableDefinition['schema']['columns'][number]) => + buildTable({ schema: { columns: [column] } }) + ) + }) + + it('forwards the target when adding a reference column', async () => { + const result = await userTableServerTool.execute( + { + operation: 'add_column', + args: { + tableId: 'tbl_1', + column: { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + }, + }, + buildToolContext() + ) + + expect(result.success).toBe(true) + expect(mockAddTableColumn).toHaveBeenCalledWith( + 'tbl_1', + expect.objectContaining({ + type: 'reference', + referenceTableId: 'tbl_accounts', + }), + expect.any(String), + { expectedWorkspaceId: 'workspace-1' } + ) + }) + + it('forwards a target-only update to the shared reference service', async () => { + const referenceTable = buildTable({ + schema: { + columns: [ + { + id: 'col_account', + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ], + }, + }) + mockGetTableById.mockResolvedValue(referenceTable) + mockUpdateColumnReference.mockResolvedValue({ + ...referenceTable, + schema: { + columns: [ + { + ...referenceTable.schema.columns[0], + referenceTableId: 'tbl_companies', + }, + ], + }, + }) + + const result = await userTableServerTool.execute( + { + operation: 'update_column', + args: { + tableId: 'tbl_1', + columnName: 'account', + referenceTableId: 'tbl_companies', + }, + }, + buildToolContext() + ) + + expect(result.success).toBe(true) + expect(mockUpdateColumnReference).toHaveBeenCalledWith( + expect.objectContaining({ + columnName: 'col_account', + referenceTableId: 'tbl_companies', + }), + expect.any(String), + { expectedWorkspaceId: 'workspace-1' } + ) + }) +}) + describe('userTableServerTool.update_column — select routing', () => { const selectTable = buildTable({ schema: { diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index d48ac344d42..89088ca71d1 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -967,6 +967,7 @@ export const userTableServerTool: BaseServerTool options?: unknown multiple?: boolean currencyCode?: string + referenceTableId?: string } | undefined if (!col?.name || !col?.type) { @@ -1090,17 +1091,21 @@ export const userTableServerTool: BaseServerTool const rawOptions = (args as Record).options const multiple = (args as Record).multiple as boolean | undefined const currencyCode = (args as Record).currencyCode as string | undefined + const referenceTableId = (args as Record).referenceTableId as + | string + | undefined if ( newType === undefined && uniqFlag === undefined && rawOptions === undefined && multiple === undefined && - currencyCode === undefined + currencyCode === undefined && + referenceTableId === undefined ) { return { success: false, message: - 'At least one of newType, unique, options, multiple, or currencyCode must be provided', + 'At least one of newType, unique, options, multiple, currencyCode, or referenceTableId must be provided', } } if (currencyCode !== undefined && !isSupportedCurrencyCode(currencyCode)) { @@ -1131,6 +1136,7 @@ export const userTableServerTool: BaseServerTool ...(rawOptions !== undefined ? { options: rawOptions } : {}), ...(multiple !== undefined ? { multiple } : {}), ...(currencyCode !== undefined ? { currencyCode } : {}), + ...(referenceTableId !== undefined ? { referenceTableId } : {}), }, }, { tableId: args.tableId } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index bd28ef56842..f260d75a17f 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -588,6 +588,7 @@ export const env = createEnv({ FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements) TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route) TABLE_ROW_TTL: z.boolean().optional(), + TABLE_REFERENCE_COLUMNS: z.boolean().optional(), CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally KNOWLEDGE_MEMBER_ACCESS: z.boolean().optional(), // Enable per-member knowledge connectors and hybrid-by-default retrieval globally diff --git a/apps/sim/lib/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index 2d28ffa4cf4..1bd1e6b9f6b 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -13,6 +13,7 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({ APPCONFIG_ENVIRONMENT: 'staging' as string | undefined, TABLES_V2_API: undefined as boolean | undefined, TABLE_ROW_TTL: undefined as boolean | undefined, + TABLE_REFERENCE_COLUMNS: undefined as boolean | undefined, CREDENTIAL_GROUPS: undefined as boolean | undefined, KNOWLEDGE_MEMBER_ACCESS: undefined as boolean | undefined, }, @@ -80,6 +81,7 @@ describe('getFeatureFlags', () => { expect(flags['trigger-eu-region']).toEqual({ enabled: false }) expect(flags['tables-v2-api']).toEqual({ enabled: false }) expect(flags['table-row-ttl']).toEqual({ enabled: false }) + expect(flags['table-reference-columns']).toEqual({ enabled: false }) expect(flags['credential-groups']).toEqual({ enabled: false }) expect(mockFetch).not.toHaveBeenCalled() }) @@ -108,6 +110,7 @@ describe('getFeatureFlags', () => { expect(flags['trigger-eu-region']).toEqual({ enabled: false }) expect(flags['tables-v2-api']).toEqual({ enabled: false }) expect(flags['table-row-ttl']).toEqual({ enabled: false }) + expect(flags['table-reference-columns']).toEqual({ enabled: false }) expect(flags['credential-groups']).toEqual({ enabled: false }) }) @@ -296,3 +299,23 @@ describe('table-row-ttl flag', () => { expect(await isFeatureEnabled('table-row-ttl')).toBe(true) }) }) + +describe('table-reference-columns flag', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isAppConfigEnabled: false }) + envRef.TABLE_REFERENCE_COLUMNS = undefined + }) + + it('uses a global fallback switch off AppConfig', async () => { + expect(await isFeatureEnabled('table-reference-columns')).toBe(false) + + envRef.TABLE_REFERENCE_COLUMNS = true + expect(await isFeatureEnabled('table-reference-columns')).toBe(true) + }) + + it('uses the global AppConfig clause', async () => { + withAppConfig({ 'table-reference-columns': { enabled: true } }) + expect(await isFeatureEnabled('table-reference-columns')).toBe(true) + }) +}) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index bf6de543ba9..f56847a9230 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -68,6 +68,13 @@ const FEATURE_FLAGS = { 'Global on/off only; existing TTL data remains readable when disabled.', fallback: 'TABLE_ROW_TTL', }, + 'table-reference-columns': { + description: + 'Gate creation, conversion, and retargeting of table Reference columns plus their ' + + 'picker and navigation UI. Existing Reference data remains readable and writable when ' + + 'disabled. Off-AppConfig falls back to TABLE_REFERENCE_COLUMNS.', + fallback: 'TABLE_REFERENCE_COLUMNS', + }, 'credential-groups': { description: 'Workspace-owned collections that gather managed OAuth credentials from external users. ' + diff --git a/apps/sim/lib/table/__tests__/column-type-registry.test.ts b/apps/sim/lib/table/__tests__/column-type-registry.test.ts index a63886a4a5c..7cce3b27347 100644 --- a/apps/sim/lib/table/__tests__/column-type-registry.test.ts +++ b/apps/sim/lib/table/__tests__/column-type-registry.test.ts @@ -9,6 +9,7 @@ * be spread across those arms, so a new type either satisfies them or fails * here. */ +import { Table as TableIcon } from '@sim/emcn/icons' import { describe, expect, it } from 'vitest' import { zonedWallClockToUtc } from '@/lib/core/utils/timezone' import type { ColumnType } from '@/lib/table/column-types' @@ -43,6 +44,15 @@ describe('registry shape', () => { expect(isColumnType('currency')).toBe(true) }) + it('registers reference columns as configured string-backed columns', () => { + const definition = COLUMN_TYPE_REGISTRY.reference + + expect(definition.label).toBe('Reference') + expect(definition.icon).toBe(TableIcon) + expect(definition.ownedMetadata).toEqual(['referenceTableId']) + expect(definition.jsonbCast).toBeNull() + }) + it('only casts to numeric/timestamptz for types whose storage is actually that', () => { // A wrong cast makes every filter and sort on the column fail in SQL. for (const definition of ALL_COLUMN_TYPES) { @@ -317,19 +327,23 @@ describe('metadata ownership', () => { const options = [{ id: 'opt_a', name: 'A' }] it.each` - label | definition | valid | needle - ${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''} - ${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'} - ${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'} - ${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'} - ${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''} - ${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'} - ${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'} - ${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'} - ${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'} - ${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''} - ${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'} - ${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'} + label | definition | valid | needle + ${'options on select'} | ${column({ type: 'select', options })} | ${true} | ${''} + ${'options on string'} | ${column({ type: 'string', options })} | ${false} | ${'cannot define options'} + ${'options on currency'} | ${column({ type: 'currency', options })} | ${false} | ${'cannot define options'} + ${'multiple on number'} | ${column({ type: 'number', multiple: true })} | ${false} | ${'cannot be multiple'} + ${'code on currency'} | ${column({ type: 'currency', currencyCode: 'USD' })} | ${true} | ${''} + ${'code on number'} | ${column({ type: 'number', currencyCode: 'USD' })} | ${false} | ${'cannot define a currency'} + ${'code on select'} | ${column({ type: 'select', currencyCode: 'USD', options })} | ${false} | ${'cannot define a currency'} + ${'unsupported code'} | ${column({ type: 'currency', currencyCode: 'ZZZ' })} | ${false} | ${'invalid currency code'} + ${'target on reference'} | ${column({ type: 'reference', referenceTableId: 'tbl_anything' })} | ${true} | ${''} + ${'missing target'} | ${column({ type: 'reference' })} | ${false} | ${'reference table'} + ${'empty target'} | ${column({ type: 'reference', referenceTableId: '' })} | ${false} | ${'reference table'} + ${'target on string'} | ${column({ type: 'string', referenceTableId: 'tbl_other' })} | ${false} | ${'reference another table'} + ${'unique on select'} | ${column({ type: 'select', unique: true, options })} | ${false} | ${'cannot be unique'} + ${'unique on currency'} | ${column({ type: 'currency', unique: true })} | ${true} | ${''} + ${'select with no option'} | ${column({ type: 'select' })} | ${false} | ${'at least one option'} + ${'unknown type'} | ${column({ type: 'percent' as ColumnDefinition['type'] })} | ${false} | ${'invalid type'} `( 'rejects $label', ({ @@ -346,6 +360,23 @@ describe('metadata ownership', () => { if (!valid) expect(result.errors.join(' ').toLowerCase()).toContain(needle.toLowerCase()) } ) + + it('accepts arbitrary row-id strings without resolving them', () => { + const column = { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + } as ColumnDefinition + const definition = COLUMN_TYPE_REGISTRY.reference + + expect(definition.coerce('not-a-real-row-id', column)).toEqual({ + ok: true, + value: 'not-a-real-row-id', + }) + expect(definition.coerce(97, column)).toEqual({ ok: true, value: '97' }) + expect(definition.coerce(true, column)).toEqual({ ok: true, value: 'true' }) + expect(definition.validateCell('not-a-real-row-id', column)).toBeNull() + }) }) /** diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts index 4a56b2c1b4a..9b026c09a74 100644 --- a/apps/sim/lib/table/application/columns.ts +++ b/apps/sim/lib/table/application/columns.ts @@ -36,6 +36,7 @@ export interface AddTableColumnInput extends TableColumnInput { options?: SelectOption[] multiple?: boolean currencyCode?: string + referenceTableId?: string } } @@ -77,6 +78,7 @@ export interface UpdateTableColumnInput extends TableColumnInput { options?: unknown multiple?: boolean currencyCode?: string + referenceTableId?: string } } diff --git a/apps/sim/lib/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts new file mode 100644 index 00000000000..7138c7fe866 --- /dev/null +++ b/apps/sim/lib/table/column-types/reference.ts @@ -0,0 +1,44 @@ +import { Table as TableIcon } from '@sim/emcn/icons' +import { stringColumnType } from '@/lib/table/column-types/string' +import type { ColumnTypeDefinition } from '@/lib/table/column-types/types' +import { MAX_REFERENCE_TABLE_ID_LENGTH } from '@/lib/table/constants' + +export const referenceColumnType: ColumnTypeDefinition = { + id: 'reference', + label: 'Reference', + icon: TableIcon, + jsonbCast: null, + storesOpaqueIds: false, + supportsUnique: true, + sampleValue: 'row_123', + ownedMetadata: ['referenceTableId'], + workflowInputType: 'string', + editor: 'text', + expandable: false, + + coerce: stringColumnType.coerce, + + validateCell(value, column) { + return typeof value === 'string' ? null : `${column.name} must be a row ID string` + }, + + validateDefinition(column) { + if (typeof column.referenceTableId !== 'string' || column.referenceTableId.length === 0) { + return [`Column "${column.name}" must define a reference table ID`] + } + if (column.referenceTableId.length > MAX_REFERENCE_TABLE_ID_LENGTH) { + return [ + `Column "${column.name}" reference table ID must be ${MAX_REFERENCE_TABLE_ID_LENGTH} characters or less`, + ] + } + return [] + }, + + formatForDisplay(value) { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + return typeof value === 'object' ? JSON.stringify(value) : String(value) + }, + + formatForInput: stringColumnType.formatForInput, +} diff --git a/apps/sim/lib/table/column-types/registry.server.test.ts b/apps/sim/lib/table/column-types/registry.server.test.ts new file mode 100644 index 00000000000..14f64905dd9 --- /dev/null +++ b/apps/sim/lib/table/column-types/registry.server.test.ts @@ -0,0 +1,103 @@ +/** + * @vitest-environment node + */ + +import { hasMockCondition, schemaMock } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' +import { assertColumnReferencesInWorkspace } from '@/lib/table/column-types/registry.server' +import type { DbTransaction } from '@/lib/table/planner' + +function transactionWithTargets(targetIds: string[]) { + const where = vi.fn().mockResolvedValue(targetIds.map((id) => ({ id }))) + const from = vi.fn(() => ({ where })) + const select = vi.fn(() => ({ from })) + return { + trx: { select } as unknown as DbTransaction, + select, + where, + } +} + +describe('assertColumnReferencesInWorkspace', () => { + it('skips the database when no column type references a table', async () => { + const { trx, select } = transactionWithTargets([]) + + await assertColumnReferencesInWorkspace(trx, 'ws_1', [ + { id: 'col_name', name: 'Name', type: 'string' }, + ]) + + expect(select).not.toHaveBeenCalled() + }) + + it('accepts active Reference targets returned for the workspace', async () => { + const { trx, select, where } = transactionWithTargets(['tbl_accounts', 'tbl_companies']) + + await assertColumnReferencesInWorkspace(trx, 'ws_1', [ + { + id: 'col_account', + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + { + id: 'col_company', + name: 'Company', + type: 'reference', + referenceTableId: 'tbl_companies', + }, + { + id: 'col_duplicate', + name: 'Duplicate', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ]) + + expect(select).toHaveBeenCalledOnce() + const condition = where.mock.calls[0][0] + expect(hasMockCondition(condition, (node) => node.type === 'eq' && node.right === 'ws_1')).toBe( + true + ) + expect( + hasMockCondition( + condition, + (node) => + node.type === 'inArray' && + node.column === schemaMock.userTableDefinitions.id && + Array.isArray(node.values) && + node.values.length === 2 + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => + node.type === 'isNull' && node.column === schemaMock.userTableDefinitions.archivedAt + ) + ).toBe(true) + }) + + it('conceals missing, archived, and cross-workspace targets as not found', async () => { + const { trx } = transactionWithTargets(['tbl_accounts']) + + await expect( + assertColumnReferencesInWorkspace(trx, 'ws_1', [ + { + id: 'col_account', + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + { + id: 'col_company', + name: 'Company', + type: 'reference', + referenceTableId: 'tbl_unavailable', + }, + ]) + ).rejects.toMatchObject({ + code: 'not_found', + message: 'Reference table "tbl_unavailable" not found in this workspace', + }) + }) +}) diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts index 5a6e23791ea..afc0c2f4a05 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -11,8 +11,9 @@ * under any other type. `currency` needs only the inbound one. */ -import { userTableRows } from '@sim/db/schema' -import { and, eq, sql } from 'drizzle-orm' +import { userTableDefinitions, userTableRows } from '@sim/db/schema' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types/registry' import type { ColumnType } from '@/lib/table/column-types/types' import type { @@ -21,7 +22,7 @@ import type { } from '@/lib/table/column-types/types.server' import type { DbTransaction } from '@/lib/table/planner' import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance' -import type { JsonValue, SelectOption } from '@/lib/table/types' +import type { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types' /** * Rewrites a column's cells from stored option **ids** to option **names**, for @@ -290,6 +291,51 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record + typeof column.referenceTableId === 'string' ? [column.referenceTableId] : [], + }, +} + +/** + * Validates every table ID referenced by column metadata in one query. + * + * This intentionally validates only the target table. Cell values remain + * opaque row-ID strings and are never checked for existence. + */ +export async function assertColumnReferencesInWorkspace( + trx: DbTransaction, + workspaceId: string, + columns: readonly ColumnDefinition[] +): Promise { + const referencedTableIds = [ + ...new Set( + columns.flatMap( + (column) => COLUMN_TYPE_SERVER_REGISTRY[column.type].referencedTableIds?.(column) ?? [] + ) + ), + ] + if (referencedTableIds.length === 0) return + + const targets = await trx + .select({ id: userTableDefinitions.id }) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + inArray(userTableDefinitions.id, referencedTableIds), + isNull(userTableDefinitions.archivedAt) + ) + ) + const foundIds = new Set(targets.map((target) => target.id)) + const missingId = referencedTableIds.find((id) => !foundIds.has(id)) + if (missingId) { + throw new OrchestrationError( + 'not_found', + `Reference table "${missingId}" not found in this workspace` + ) + } } /** The inbound migration for a target type, if it has one. */ diff --git a/apps/sim/lib/table/column-types/registry.ts b/apps/sim/lib/table/column-types/registry.ts index 4e4d0f82bc6..34f53e46b37 100644 --- a/apps/sim/lib/table/column-types/registry.ts +++ b/apps/sim/lib/table/column-types/registry.ts @@ -24,6 +24,7 @@ import { currencyColumnType } from '@/lib/table/column-types/currency' import { dateColumnType } from '@/lib/table/column-types/date' import { jsonColumnType } from '@/lib/table/column-types/json' import { numberColumnType } from '@/lib/table/column-types/number' +import { referenceColumnType } from '@/lib/table/column-types/reference' import { MULTI_SELECT_OPERATORS, MULTI_SELECT_OPS, @@ -52,6 +53,7 @@ export const COLUMN_TYPE_REGISTRY: Record = { ttl: ttlColumnType, json: jsonColumnType, select: selectColumnType, + reference: referenceColumnType, currency: currencyColumnType, } @@ -113,9 +115,8 @@ export function validateTypeMetadata(column: ColumnDefinition): string[] { * A column's type-specific metadata, as a spreadable object. * * Callers that copy a column — the API response serializer, the undo snapshot — - * used to name `options`/`multiple`/`currencyCode` by hand, so a new type's - * metadata was stored but silently dropped on the way out. Reading the key list - * keeps them zero-edit. + * used to name type-specific keys by hand, so a new type's metadata was stored + * but silently dropped on the way out. Reading the key list keeps them zero-edit. */ export function typeMetadataOf(column: ColumnDefinition): Partial { const metadata: Partial = {} diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts index 48f34f812e9..b569c73a0ea 100644 --- a/apps/sim/lib/table/column-types/types.server.ts +++ b/apps/sim/lib/table/column-types/types.server.ts @@ -1,9 +1,9 @@ /** - * The server-only half of a column type: rewriting stored cells when a column - * is converted into or out of this type. + * The server-only half of a column type: database-backed definition checks and + * stored-cell rewrites for conversion into or out of the type. * * Separate from `types.ts` so the client-safe definition never references a - * drizzle transaction type. Mirrors `connectors/`'s `ConnectorMeta` / + * Drizzle transaction type. Mirrors `connectors/`'s `ConnectorMeta` / * `ConnectorConfig` split. */ @@ -31,6 +31,12 @@ export interface ColumnCellMigrationContext { export type ColumnCellMigration = (context: ColumnCellMigrationContext) => Promise export interface ColumnTypeServerDefinition { + /** + * Table IDs named by this column's type-specific metadata. The server + * registry uses this to validate cross-table references in one batch before + * a schema is persisted. Omitted by types that do not reference tables. + */ + readonly referencedTableIds?: (column: ColumnDefinition) => readonly string[] /** * Rewrites cells into this type's canonical storage shape when a column is * converted **to** it. Omitted when the stored bytes are already correct. diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index 72edeead0d0..807b0c3e6ce 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -12,8 +12,8 @@ * `scripts/check-client-boundary-imports.ts` only forbids calling a * `'use client'` export from a server surface). It must NOT reach `@sim/db`, * `drizzle-orm`, or `next/server` — the tables grid imports it directly. - * - `ColumnTypeServerDefinition` (in `types.server.ts`) adds the one genuinely - * server-only concern: rewriting stored cells inside a transaction. + * - `ColumnTypeServerDefinition` (in `types.server.ts`) adds database-backed + * definition checks and stored-cell rewrites inside a transaction. * * This mirrors `connectors/types.ts`'s `ConnectorMeta` / `ConnectorConfig` * split and its `registry.ts` / `registry.server.ts` pair. @@ -40,6 +40,7 @@ export const COLUMN_TYPES = [ 'ttl', 'json', 'select', + 'reference', ] as const export type ColumnType = (typeof COLUMN_TYPES)[number] @@ -62,7 +63,12 @@ export type ColumnCellEditor = * means extending this list and that type's `ownedMetadata` — not editing the * validator. */ -export const TYPE_SPECIFIC_COLUMN_KEYS = ['options', 'multiple', 'currencyCode'] as const +export const TYPE_SPECIFIC_COLUMN_KEYS = [ + 'options', + 'multiple', + 'currencyCode', + 'referenceTableId', +] as const export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number] diff --git a/apps/sim/lib/table/columns/reference-metadata.test.ts b/apps/sim/lib/table/columns/reference-metadata.test.ts new file mode 100644 index 00000000000..fa61729fd91 --- /dev/null +++ b/apps/sim/lib/table/columns/reference-metadata.test.ts @@ -0,0 +1,319 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_REFERENCE_TABLE_ID_LENGTH } from '@/lib/table/constants' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + withLockedTable: vi.fn(), + assertColumnReferencesInWorkspace: vi.fn(), + migrationFrom: vi.fn(), + migrationTo: vi.fn(), + writeBackCoercedCells: vi.fn(), + assertTableReferenceColumnsEnabled: vi.fn(), + set: vi.fn(), + where: vi.fn(), +})) + +vi.mock('@/lib/table/service', () => ({ withLockedTable: mocks.withLockedTable })) +vi.mock('@/lib/table/column-types/registry.server', () => ({ + assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace, + migrationFrom: mocks.migrationFrom, + migrationTo: mocks.migrationTo, + writeBackCoercedCells: mocks.writeBackCoercedCells, +})) +vi.mock('@/lib/table/reference-columns/availability', () => ({ + assertTableReferenceColumnsEnabled: mocks.assertTableReferenceColumnsEnabled, +})) + +import { + addTableColumn, + updateColumnReference, + updateColumnType, +} from '@/lib/table/columns/service' + +const BASE_TABLE = { + id: 'tbl_people', + name: 'People', + workspaceId: 'ws_1', + schema: { + columns: [{ id: 'col_name', name: 'Name', type: 'string' }], + }, + metadata: null, + rowCount: 0, +} as unknown as TableDefinition + +function tableWithReference(referenceTableId = 'tbl_accounts'): TableDefinition { + return { + ...BASE_TABLE, + schema: { + columns: [ + { + id: 'col_account', + name: 'Account', + type: 'reference', + referenceTableId, + }, + ], + }, + } +} + +describe('reference column metadata persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) + mocks.migrationFrom.mockReturnValue(undefined) + mocks.migrationTo.mockReturnValue(undefined) + mocks.writeBackCoercedCells.mockResolvedValue(undefined) + mocks.assertTableReferenceColumnsEnabled.mockResolvedValue(undefined) + mocks.where.mockResolvedValue(undefined) + mocks.set.mockReturnValue({ where: mocks.where }) + }) + + function useTable(table: TableDefinition) { + const trx = { + execute: vi.fn().mockResolvedValue([]), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ limit: vi.fn().mockResolvedValue([]) })), + })), + })), + })), + update: vi.fn(() => ({ set: mocks.set })), + } + mocks.withLockedTable.mockImplementationOnce( + async (_tableId, mutate: (locked: TableDefinition, tx: typeof trx) => Promise) => + mutate(table, trx) + ) + return trx + } + + it('retains referenceTableId when adding a reference column', async () => { + useTable(BASE_TABLE) + + const updated = await addTableColumn( + 'tbl_people', + { name: 'Account', type: 'reference', referenceTableId: 'tbl_accounts' }, + 'req_1' + ) + + expect(updated.schema.columns.at(-1)).toMatchObject({ + name: 'Account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }) + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + 'ws_1', + [expect.objectContaining({ referenceTableId: 'tbl_accounts' })] + ) + }) + + it('rejects Reference creation before locking when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + addTableColumn( + 'tbl_people', + { name: 'Account', type: 'reference', referenceTableId: 'tbl_accounts' }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.withLockedTable).not.toHaveBeenCalled() + }) + + it('rejects conversion to Reference before locking when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + updateColumnType( + { + tableId: 'tbl_people', + columnName: 'col_name', + newType: 'reference', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.withLockedTable).not.toHaveBeenCalled() + }) + + it('rejects Reference retargeting before locking when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_companies', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.withLockedTable).not.toHaveBeenCalled() + }) + + it('retains the supplied target when converting a column to reference', async () => { + useTable(BASE_TABLE) + + const updated = await updateColumnType( + { + tableId: 'tbl_people', + columnName: 'col_name', + newType: 'reference', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + + expect(updated.schema.columns[0]).toMatchObject({ + id: 'col_name', + type: 'reference', + referenceTableId: 'tbl_accounts', + }) + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + 'ws_1', + [expect.objectContaining({ referenceTableId: 'tbl_accounts' })] + ) + }) + + it('changes a reference target without reading or rewriting rows', async () => { + const trx = useTable(tableWithReference()) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_companies', + }, + 'req_1' + ) + + expect(updated.schema.columns[0]).toMatchObject({ referenceTableId: 'tbl_companies' }) + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + 'ws_1', + [expect.objectContaining({ referenceTableId: 'tbl_companies' })] + ) + expect(trx.select).not.toHaveBeenCalled() + expect(trx.execute).not.toHaveBeenCalled() + expect(trx.update).toHaveBeenCalledOnce() + }) + + it('rejects reference metadata on a non-reference column', async () => { + const trx = useTable(BASE_TABLE) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_name', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(trx.update).not.toHaveBeenCalled() + }) + + it('leaves the source schema unchanged when the target table is unavailable', async () => { + const trx = useTable(tableWithReference()) + mocks.assertColumnReferencesInWorkspace.mockRejectedValueOnce({ code: 'not_found' }) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_missing', + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(trx.update).not.toHaveBeenCalled() + }) + + it('returns the locked table unchanged when the target is already set', async () => { + const table = tableWithReference() + const trx = useTable(table) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_accounts', + }, + 'req_1' + ) + + expect(updated).toBe(table) + expect(trx.update).not.toHaveBeenCalled() + }) + + it('does not rewrite the schema when the target and supplied constraints are unchanged', async () => { + const table = tableWithReference() + table.schema.columns[0] = { ...table.schema.columns[0], required: true, unique: true } + const trx = useTable(table) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: 'tbl_accounts', + required: true, + unique: true, + }, + 'req_1' + ) + + expect(updated).toBe(table) + expect(trx.update).not.toHaveBeenCalled() + }) + + it('accepts a reference table ID at the standard identifier length', async () => { + const maximumId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH) + useTable(tableWithReference()) + + const updated = await updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: maximumId, + }, + 'req_1' + ) + + expect(updated.schema.columns[0]).toMatchObject({ referenceTableId: maximumId }) + expect(mocks.set).toHaveBeenCalledOnce() + }) + + it('rejects a reference table ID longer than the standard identifier length', async () => { + const oversizedId = 't'.repeat(MAX_REFERENCE_TABLE_ID_LENGTH + 1) + const trx = useTable(tableWithReference()) + + await expect( + updateColumnReference( + { + tableId: 'tbl_people', + columnName: 'col_account', + referenceTableId: oversizedId, + }, + 'req_1' + ) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(trx.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index fa0a274d146..06566c5c4e9 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -30,6 +30,7 @@ import { valueForTypeConversion, } from '@/lib/table/column-types' import { + assertColumnReferencesInWorkspace, migrationFrom, migrationTo, writeBackCoercedCells, @@ -38,6 +39,7 @@ import { COLUMN_TYPES, getMaxRowSizeBytes, NAME_PATTERN, TABLE_LIMITS } from '@/ import { resolveCurrencyCode } from '@/lib/table/currency' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' +import { assertTableReferenceColumnsEnabled } from '@/lib/table/reference-columns/availability' import { stripGroupExecutions } from '@/lib/table/rows/executions' import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance' import { assertValidSchema } from '@/lib/table/schema-invariants' @@ -57,6 +59,7 @@ import type { UpdateColumnConstraintsData, UpdateColumnCurrencyData, UpdateColumnOptionsData, + UpdateColumnReferenceData, UpdateColumnTypeData, } from '@/lib/table/types' import { validateColumnDefinition } from '@/lib/table/validation' @@ -128,11 +131,13 @@ export async function addTableColumn( options?: SelectOption[] multiple?: boolean currencyCode?: string + referenceTableId?: string }, requestId: string, options?: ColumnMutationOptions ): Promise { if (column.type === 'ttl') await assertTableRowTtlEnabled() + if (column.type === 'reference') await assertTableReferenceColumnsEnabled() return withLockedTable( tableId, @@ -181,6 +186,9 @@ export async function addTableColumn( unique: column.unique ?? false, ...(column.options ? { options: column.options } : {}), ...(column.multiple ? { multiple: true } : {}), + ...(column.referenceTableId !== undefined + ? { referenceTableId: column.referenceTableId } + : {}), ...columnTypeById(column.type).defaultMetadata?.(column as ColumnDefinition), } @@ -191,6 +199,7 @@ export async function addTableColumn( `Invalid column: ${columnValidation.errors.join('; ')}` ) } + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [newColumn]) const newColumnId = getColumnId(newColumn) @@ -861,6 +870,7 @@ export async function updateColumnType( options?: ColumnMutationOptions ): Promise { if (data.newType === 'ttl') await assertTableRowTtlEnabled() + if (data.newType === 'reference') await assertTableReferenceColumnsEnabled() return withLockedTable( data.tableId, @@ -904,7 +914,8 @@ export async function updateColumnType( data.unique !== undefined || data.options !== undefined || data.multiple !== undefined || - data.currencyCode !== undefined + data.currencyCode !== undefined || + data.referenceTableId !== undefined if (carriesOtherWork) { throw new OrchestrationError( 'validation', @@ -958,6 +969,7 @@ export async function updateColumnType( isSelectType, targetMultiple: !!targetMultiple, }) + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [convertedColumn]) 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 @@ -1470,6 +1482,92 @@ export async function updateColumnCurrency( ) } +/** + * Changes the table targeted by a `reference` column. + * + * Cells already store plain row-ID strings, so changing the target updates only + * the column schema. The target must be an active table in the same workspace; + * stored row IDs remain opaque strings and are not checked for existence. + */ +export async function updateColumnReference( + data: UpdateColumnReferenceData, + requestId: string, + options?: ColumnMutationOptions +): Promise { + await assertTableReferenceColumnsEnabled() + + return withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + + const schema = table.schema + const columnIndex = schema.columns.findIndex((column) => + columnMatchesRef(column, data.columnName) + ) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } + + const column = schema.columns[columnIndex] + if (column.type !== 'reference') { + throw new OrchestrationError( + 'validation', + `Cannot set a reference table on column "${column.name}" of type "${column.type}"` + ) + } + + const updatedColumn: ColumnDefinition = { + ...column, + referenceTableId: data.referenceTableId, + } + const columnValidation = validateColumnDefinition(updatedColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } + await assertColumnReferencesInWorkspace(trx, table.workspaceId, [updatedColumn]) + + const constrained = await applyConstraints( + trx, + data.tableId, + table.workspaceId, + updatedColumn, + getColumnId(column), + data + ) + const renamePending = data.newName !== undefined && data.newName !== column.name + if ( + constrained.required === column.required && + constrained.unique === column.unique && + updatedColumn.referenceTableId === column.referenceTableId && + !renamePending + ) { + return table + } + + const withReference = schema.columns.map((existing, index) => + index === columnIndex ? constrained : existing + ) + const updatedColumns = withReference.map((existing, index) => + index === columnIndex + ? applyPendingRename(withReference, columnIndex, data.newName) + : existing + ) + const updated = await persistColumns(trx, table, updatedColumns) + + logger.info( + `[${requestId}] Set reference table for column "${column.name}" to "${data.referenceTableId}" in table ${data.tableId}` + ) + + return updated + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) +} + /** * Rows whose cell counts as empty for a `required` constraint: the key is * missing, the value is JSON null, or it is an emptied multiselect `[]`. diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 56e1f25c05b..271f6c83cc5 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -12,6 +12,9 @@ import { env, envNumber } from '@/lib/core/config/env' */ export const MAX_TABLE_BATCH_ITEMS = 100 +/** Maximum length of the table identifier stored by a reference column. */ +export const MAX_REFERENCE_TABLE_ID_LENGTH = 128 + export const DEFAULT_TABLE_VIEW_NAME = 'Default' export const TABLE_LIMITS = { diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index f8259213a45..5e93340952f 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -164,6 +164,11 @@ describe('import', () => { expect(coerceValue('yes', 'boolean')).toBeNull() }) + it('keeps imported reference values as row-id strings', () => { + expect(coerceValue('row_external_123', 'reference')).toBe('row_external_123') + expect(coerceValue(97, 'reference')).toBe('97') + }) + it('keeps date-only values as calendar dates, preserves datetime wall times with their offset, and falls back to the original string', () => { expect(coerceValue('2024-01-01', 'date')).toBe('2024-01-01') expect(coerceValue('2024-01-01T12:30:00-07:00', 'date')).toBe('2024-01-01T12:30:00-07:00') diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 50ea7cfc3b2..6d9f979c17f 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -512,6 +512,8 @@ export function coerceValue( return String(value) } } + case 'reference': + return String(value) default: return String(value) } diff --git a/apps/sim/lib/table/orchestration/columns.test.ts b/apps/sim/lib/table/orchestration/columns.test.ts index d99eff54342..505dc1ad09e 100644 --- a/apps/sim/lib/table/orchestration/columns.test.ts +++ b/apps/sim/lib/table/orchestration/columns.test.ts @@ -13,6 +13,7 @@ const { mockUpdateColumnOptions, mockUpdateColumnConstraints, mockUpdateColumnCurrency, + mockUpdateColumnReference, mockRecordAudit, } = vi.hoisted(() => ({ mockRenameColumn: vi.fn(), @@ -20,6 +21,7 @@ const { mockUpdateColumnOptions: vi.fn(), mockUpdateColumnConstraints: vi.fn(), mockUpdateColumnCurrency: vi.fn(), + mockUpdateColumnReference: vi.fn(), mockRecordAudit: vi.fn(), })) @@ -33,6 +35,7 @@ vi.mock('@/lib/table/columns/service', () => ({ renameColumn: mockRenameColumn, updateColumnConstraints: mockUpdateColumnConstraints, updateColumnCurrency: mockUpdateColumnCurrency, + updateColumnReference: mockUpdateColumnReference, updateColumnOptions: mockUpdateColumnOptions, updateColumnType: mockUpdateColumnType, })) @@ -48,12 +51,18 @@ const SELECT_COLUMN = { options: [{ id: 'opt_open', name: 'Open' }], } const TEXT_COLUMN = { id: 'col-2', name: 'Priority', type: 'text' as const } +const REFERENCE_COLUMN = { + id: 'col-3', + name: 'Account', + type: 'reference' as const, + referenceTableId: 'tbl_accounts', +} const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', - schema: { columns: [SELECT_COLUMN, TEXT_COLUMN] }, + schema: { columns: [SELECT_COLUMN, TEXT_COLUMN, REFERENCE_COLUMN] }, } as unknown as TableDefinition const UPDATED = { schema: { columns: [SELECT_COLUMN] } } as unknown as TableDefinition @@ -68,6 +77,15 @@ function run(updates: Record, columnName = 'Status') { }) } +function expectNoServiceWrite() { + expect(mockRenameColumn).not.toHaveBeenCalled() + expect(mockUpdateColumnType).not.toHaveBeenCalled() + expect(mockUpdateColumnOptions).not.toHaveBeenCalled() + expect(mockUpdateColumnConstraints).not.toHaveBeenCalled() + expect(mockUpdateColumnCurrency).not.toHaveBeenCalled() + expect(mockUpdateColumnReference).not.toHaveBeenCalled() +} + describe('performUpdateTableColumn', () => { beforeEach(() => { vi.clearAllMocks() @@ -76,6 +94,7 @@ describe('performUpdateTableColumn', () => { mockUpdateColumnOptions.mockResolvedValue(UPDATED) mockUpdateColumnConstraints.mockResolvedValue(UPDATED) mockUpdateColumnCurrency.mockResolvedValue(UPDATED) + mockUpdateColumnReference.mockResolvedValue(UPDATED) }) it('refuses to make a select column unique before writing anything', async () => { @@ -161,6 +180,71 @@ describe('performUpdateTableColumn', () => { expect(mockUpdateColumnType).not.toHaveBeenCalled() }) + it('carries the target through a conversion to reference', async () => { + await run({ type: 'reference', referenceTableId: 'tbl_accounts' }, 'Priority') + + expect(mockUpdateColumnReference).not.toHaveBeenCalled() + expect(mockUpdateColumnType).toHaveBeenCalledWith( + expect.objectContaining({ + newType: 'reference', + referenceTableId: 'tbl_accounts', + }), + 'req-1' + ) + }) + + it('routes a target-only reference update through the schema-only service', async () => { + await run({ referenceTableId: 'tbl_companies' }, 'Account') + + expect(mockUpdateColumnType).not.toHaveBeenCalled() + expect(mockUpdateColumnReference).toHaveBeenCalledWith( + expect.objectContaining({ + columnName: 'col-3', + referenceTableId: 'tbl_companies', + }), + 'req-1' + ) + }) + + it('folds reference metadata, constraints, and rename into one schema write', async () => { + await run({ referenceTableId: 'tbl_companies', required: true, name: 'Company' }, 'Account') + + expect(mockRenameColumn).not.toHaveBeenCalled() + expect(mockUpdateColumnConstraints).not.toHaveBeenCalled() + expect(mockUpdateColumnReference).toHaveBeenCalledWith( + expect.objectContaining({ + referenceTableId: 'tbl_companies', + required: true, + newName: 'Company', + }), + 'req-1' + ) + }) + + it('rejects reference metadata when the resulting type is not reference', async () => { + const result = await run({ referenceTableId: 'tbl_accounts' }, 'Priority') + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expect(mockUpdateColumnReference).not.toHaveBeenCalled() + }) + + it('rejects select options when converting a column to reference', async () => { + const result = await run( + { type: 'reference', referenceTableId: 'tbl_accounts', options: ['Open'] }, + 'Priority' + ) + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expectNoServiceWrite() + }) + + it('rejects select multiple metadata when updating a reference column', async () => { + const result = await run({ referenceTableId: 'tbl_companies', multiple: true }, 'Account') + + expect(result).toMatchObject({ success: false, errorCode: 'validation' }) + expectNoServiceWrite() + }) + it('reports an empty payload as a validation failure', async () => { const result = await run({}) diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 48f8f5ffd3d..21bd0bca189 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -14,6 +14,7 @@ import { updateColumnConstraints, updateColumnCurrency, updateColumnOptions, + updateColumnReference, updateColumnType, } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' @@ -42,6 +43,7 @@ export interface PerformUpdateTableColumnParams { options?: unknown multiple?: boolean currencyCode?: string + referenceTableId?: string } requestId?: string expectedWorkspaceId?: string @@ -114,6 +116,7 @@ export async function performUpdateTableColumn( const typedWriteRuns = typeChanging || updates.currencyCode !== undefined || + updates.referenceTableId !== undefined || options !== undefined || updates.multiple !== undefined const constraintsWriteRuns = @@ -140,6 +143,21 @@ export async function performUpdateTableColumn( ) } } + if (updates.referenceTableId !== undefined && resultingType !== 'reference') { + return fail( + `Cannot set a reference table on column "${columnName}" of type "${resultingType}"`, + 'validation' + ) + } + if ( + (updates.options !== undefined || updates.multiple !== undefined) && + resultingType !== 'select' + ) { + return fail( + `Cannot set select metadata on column "${columnName}" of type "${resultingType}"`, + 'validation' + ) + } // The rename runs last, so a name already taken would fail after the typed // write committed. This is the only rename failure a caller can cause; // catching it here leaves just the concurrent-collision race. @@ -177,6 +195,9 @@ export async function performUpdateTableColumn( ...(options !== undefined ? { options } : {}), ...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}), ...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}), + ...(updates.referenceTableId !== undefined + ? { referenceTableId: updates.referenceTableId } + : {}), // Forwarded so the conversion validates against the constraints this // same request is about to set, not the column's current ones. ...(updates.required !== undefined ? { required: updates.required } : {}), @@ -202,6 +223,19 @@ export async function performUpdateTableColumn( requestId, ...workspaceMutationOptions(params.expectedWorkspaceId) ) + } else if (updates.referenceTableId !== undefined) { + updated = await updateColumnReference( + { + tableId, + columnName: columnRef, + referenceTableId: updates.referenceTableId, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + ...renameWithTypedWrite, + }, + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) + ) } else if (options !== undefined || updates.multiple !== undefined) { updated = await updateColumnOptions( { diff --git a/apps/sim/lib/table/reference-columns/availability.ts b/apps/sim/lib/table/reference-columns/availability.ts new file mode 100644 index 00000000000..d50c9a5327b --- /dev/null +++ b/apps/sim/lib/table/reference-columns/availability.ts @@ -0,0 +1,17 @@ +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export const TABLE_REFERENCE_COLUMNS_DISABLED_MESSAGE = + 'Reference columns are not enabled for this deployment' + +/** Resolves the global runtime gate for Reference column behavior. */ +export function areTableReferenceColumnsEnabled(): Promise { + return isFeatureEnabled('table-reference-columns') +} + +/** Rejects mutations that introduce or reconfigure a Reference column. */ +export async function assertTableReferenceColumnsEnabled(): Promise { + if (!(await areTableReferenceColumnsEnabled())) { + throw new OrchestrationError('forbidden', TABLE_REFERENCE_COLUMNS_DISABLED_MESSAGE) + } +} diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts index d3141b6394b..0250581ab55 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -12,8 +12,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import type { TableSchema } from '@/lib/table/types' -const { mockAssertTableRowTtlEnabled } = vi.hoisted(() => ({ - mockAssertTableRowTtlEnabled: vi.fn(), +const mocks = vi.hoisted(() => ({ + assertColumnReferencesInWorkspace: vi.fn(), + assertTableReferenceColumnsEnabled: vi.fn(), + assertTableRowTtlEnabled: vi.fn(), +})) + +vi.mock('@/lib/table/column-types/registry.server', () => ({ + assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace, +})) + +vi.mock('@/lib/table/reference-columns/availability', () => ({ + assertTableReferenceColumnsEnabled: mocks.assertTableReferenceColumnsEnabled, })) vi.mock('@/lib/realtime/notify', () => ({ @@ -26,7 +36,7 @@ vi.mock('@/lib/table/billing', () => ({ })) vi.mock('@/lib/table/ttl-availability', () => ({ - assertTableRowTtlEnabled: mockAssertTableRowTtlEnabled, + assertTableRowTtlEnabled: mocks.assertTableRowTtlEnabled, })) import { createTable, getTableById } from '@/lib/table/service' @@ -66,11 +76,15 @@ describe('createTable schema invariants', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockAssertTableRowTtlEnabled.mockResolvedValue(undefined) + mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) + mocks.assertTableReferenceColumnsEnabled.mockResolvedValue(undefined) + mocks.assertTableRowTtlEnabled.mockResolvedValue(undefined) }) it('rejects a TTL schema before persistence when the feature is disabled', async () => { - mockAssertTableRowTtlEnabled.mockRejectedValue(new Error('Expiration columns are not enabled')) + mocks.assertTableRowTtlEnabled.mockRejectedValue( + new Error('Expiration columns are not enabled') + ) await expect( create({ columns: [{ name: 'expires_at', type: 'ttl' }] } as TableSchema) @@ -131,6 +145,62 @@ describe('createTable schema invariants', () => { }) ) }) + + it('validates Reference targets before persisting the new table', async () => { + queueTableRows(schemaMock.userTableDefinitions, [{ count: 0 }]) + + await create({ + columns: [ + { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ], + } as TableSchema) + + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + WORKSPACE_ID, + [expect.objectContaining({ referenceTableId: 'tbl_accounts' })] + ) + }) + + it('rejects a Reference schema before opening a transaction when the feature is disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect( + create({ + columns: [ + { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ], + } as TableSchema) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) + + it('does not insert a table when a Reference target is unavailable', async () => { + mocks.assertColumnReferencesInWorkspace.mockRejectedValueOnce({ code: 'not_found' }) + + await expect( + create({ + columns: [ + { + name: 'account', + type: 'reference', + referenceTableId: 'tbl_missing', + }, + ], + } as TableSchema) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) }) const TABLE_ID = '0f2b1a4a-1e0e-4b4a-9a0f-0a2b3c4d5e6f' diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 36328223e2a..2a1669b8453 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -36,6 +36,7 @@ import { resolveRestoredFolderId } from '@/lib/folders/queries' import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys' +import { assertColumnReferencesInWorkspace } from '@/lib/table/column-types/registry.server' import { COLUMN_TYPES, DEFAULT_TABLE_VIEW_NAME, @@ -52,6 +53,7 @@ import { import { assertSchemaMutable, TableLockedError } from '@/lib/table/mutation-locks' import { nKeysBetween } from '@/lib/table/order-key' import type { DbTransaction } from '@/lib/table/planner' +import { assertTableReferenceColumnsEnabled } from '@/lib/table/reference-columns/availability' import { createExactEmptyTableRowSecretProvenance, mutateTableRowsWithSecretProvenance, @@ -564,6 +566,9 @@ export async function createTable( if (data.schema.columns.some((column) => column.type === 'ttl')) { await assertTableRowTtlEnabled() } + if (data.schema.columns.some((column) => column.type === 'reference')) { + await assertTableReferenceColumnsEnabled() + } const tableId = `tbl_${generateId().replace(/-/g, '')}` const now = new Date() @@ -628,6 +633,7 @@ export async function createTable( await trx.execute( sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR NO KEY UPDATE` ) + await assertColumnReferencesInWorkspace(trx, data.workspaceId, schema.columns) const [{ count: existingCount }] = await trx .select({ count: count() }) diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 98747c75ed6..054608d8f63 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -74,6 +74,11 @@ export interface ColumnDefinition { * single row. Absent means {@link DEFAULT_CURRENCY_CODE}. */ currencyCode?: string + /** + * Target table for a `reference` column. Cells store row ID strings from this + * table; the IDs are intentionally not checked for existence on write. + */ + referenceTableId?: string } /** The column `type` discriminator, named so callers don't index into the interface. */ @@ -903,6 +908,8 @@ export interface UpdateColumnTypeData { multiple?: boolean /** Currency to set when changing to the `currency` type. */ currencyCode?: string + /** Target table to set when changing to the `reference` type. */ + referenceTableId?: string /** * The `unique` value the same request is about to set. Validated inside the * retype against the post-conversion values, because the conversion is what @@ -955,6 +962,21 @@ export interface UpdateColumnCurrencyData { currencyCode: string } +/** + * Payload for changing the table targeted by a `reference` column. Cells keep + * storing the same row-ID strings, so this is a schema-only update. + */ +export interface UpdateColumnReferenceData { + tableId: string + columnName: string + /** A rename to apply in the SAME transaction as this write. */ + newName?: string + /** Constraints to apply in the SAME transaction as this write. */ + unique?: boolean + required?: boolean + referenceTableId: string +} + export interface UpdateColumnConstraintsData { tableId: string columnName: string diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index e5fd89c3d2d..1d5faf5f37d 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -51,6 +51,7 @@ const FOREIGN_METADATA_VERB: Record = { options: 'define options', multiple: 'be multiple', currencyCode: 'define a currency', + referenceTableId: 'reference another table', } type ValidationSuccess = { valid: true } diff --git a/apps/sim/lib/workspaces/host-context.test.ts b/apps/sim/lib/workspaces/host-context.test.ts index 19cc0e1cfe8..2a34c4568ca 100644 --- a/apps/sim/lib/workspaces/host-context.test.ts +++ b/apps/sim/lib/workspaces/host-context.test.ts @@ -7,10 +7,12 @@ const { mockCheckWorkspaceAccess, mockGetWorkspaceOwnerSubscriptionAccess, mockGetOrganizationSettingsAccess, + mockAreTableReferenceColumnsEnabled, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(), mockGetOrganizationSettingsAccess: vi.fn(), + mockAreTableReferenceColumnsEnabled: vi.fn(), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -25,6 +27,10 @@ vi.mock('@/lib/billing/core/workspace-access', () => ({ getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess, })) +vi.mock('@/lib/table/reference-columns/availability', () => ({ + areTableReferenceColumnsEnabled: mockAreTableReferenceColumnsEnabled, +})) + import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' const OWNER_BILLING = { @@ -67,6 +73,7 @@ describe('getWorkspaceHostContextForViewer', () => { beforeEach(() => { vi.clearAllMocks() mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(OWNER_BILLING) + mockAreTableReferenceColumnsEnabled.mockResolvedValue(true) }) it('returns host membership and route permission for an internal member', async () => { @@ -83,6 +90,7 @@ describe('getWorkspaceHostContextForViewer', () => { expect.objectContaining({ workspace: expect.objectContaining({ allowPersonalApiKeys: false }), hostOrganizationId: 'org-host', + features: expect.objectContaining({ referenceColumns: true }), viewer: { permission: 'write', isHostOrganizationMember: true, diff --git a/apps/sim/lib/workspaces/host-context.ts b/apps/sim/lib/workspaces/host-context.ts index 78cd140507f..e701e54dfbe 100644 --- a/apps/sim/lib/workspaces/host-context.ts +++ b/apps/sim/lib/workspaces/host-context.ts @@ -4,6 +4,7 @@ import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspac import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability' import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' +import { areTableReferenceColumnsEnabled } from '@/lib/table/reference-columns/availability' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' /** @@ -23,11 +24,12 @@ async function resolveWorkspaceHostContextForViewer( } const hostOrganizationId = access.workspace.organizationId - const [ownerBilling, hostOrganizationAccess] = await Promise.all([ + const [ownerBilling, hostOrganizationAccess, referenceColumnsEnabled] = await Promise.all([ getWorkspaceOwnerSubscriptionAccess(workspaceId), hostOrganizationId ? getOrganizationSettingsAccess(hostOrganizationId, userId) : Promise.resolve({ role: null, isMember: false, isAdmin: false }), + areTableReferenceColumnsEnabled(), ]) const [credentialGroupsAvailable, knowledgeMemberAccessAvailable] = await Promise.all([ isCredentialGroupsAvailable({ workspaceId, ownerBilling }), @@ -53,6 +55,7 @@ async function resolveWorkspaceHostContextForViewer( features: { credentialGroups: credentialGroupsAvailable, knowledgeMemberAccess: knowledgeMemberAccessAvailable, + referenceColumns: referenceColumnsEnabled, }, } } diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 1da15ace218..7d15c8f8b27 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -63,6 +63,7 @@ export type TableUndoAction = // Likewise for a `currency` column: without its code the restore would // silently re-denominate every cell to the default currency. columnCurrencyCode?: string + columnReferenceTableId?: string cellData: Array<{ rowId: string; value: unknown }> previousOrder: string[] | null previousWidth: number | null diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index ad31f63d6b0..f754dc2a688 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -114,6 +114,7 @@ app: # Generate using: openssl rand -hex 32 CRON_SECRET: "" # OPTIONAL - required only if cronjobs.enabled=true, authenticates scheduled job requests TABLE_ROW_TTL: "" # Enable TTL columns and expired-row cleanup when AppConfig is unavailable + TABLE_REFERENCE_COLUMNS: "" # Enable Reference columns when AppConfig is unavailable KNOWLEDGE_MEMBER_ACCESS: "" # Enable per-member knowledge connectors when AppConfig is unavailable # Optional: API Key Encryption (RECOMMENDED for production) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index cab4ac6f8a8..3f0d7f285ee 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -181,7 +181,16 @@ export type AddTableColumnBody = { column: { id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean options?: Array<{ @@ -190,6 +199,7 @@ export type AddTableColumnBody = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string position?: number } } @@ -198,7 +208,16 @@ type AddTableColumnResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -208,6 +227,7 @@ type AddTableColumnResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -248,7 +268,16 @@ export type AddWorkflowGroupBody = { } outputColumns: Array<{ name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean }> @@ -283,7 +312,16 @@ type AddWorkflowGroupResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -293,6 +331,7 @@ type AddWorkflowGroupResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -1886,7 +1925,16 @@ export type CreateTableBody = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean options?: Array<{ @@ -1895,6 +1943,7 @@ export type CreateTableBody = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } folderPath?: CreateTableBodyRef0 @@ -1918,7 +1967,16 @@ type CreateTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -1928,6 +1986,7 @@ type CreateTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -2870,7 +2929,16 @@ type DeleteTableColumnResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -2880,6 +2948,7 @@ type DeleteTableColumnResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -3131,7 +3200,16 @@ type DeleteWorkflowGroupResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -3141,6 +3219,7 @@ type DeleteWorkflowGroupResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -4312,7 +4391,16 @@ type GetTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -4322,6 +4410,7 @@ type GetTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -6072,7 +6161,16 @@ type ListTablesResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -6082,6 +6180,7 @@ type ListTablesResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -7278,7 +7377,16 @@ type RestoreTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -7288,6 +7396,7 @@ type RestoreTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -8418,7 +8527,16 @@ type UpdateTableResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -8428,6 +8546,7 @@ type UpdateTableResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } rowCount: number @@ -8460,7 +8579,16 @@ export type UpdateTableColumnBody = { columnName: string updates: { name?: string - type?: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type?: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean options?: Array<{ @@ -8469,6 +8597,7 @@ export type UpdateTableColumnBody = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string } } @@ -8476,7 +8605,16 @@ type UpdateTableColumnResponseRef0 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -8486,6 +8624,7 @@ type UpdateTableColumnResponseRef0 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } @@ -8739,7 +8878,16 @@ export type UpdateWorkflowGroupBody = { }> newOutputColumns?: Array<{ name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required?: boolean unique?: boolean }> @@ -8785,7 +8933,16 @@ type UpdateWorkflowGroupResponseRef1 = { columns: Array<{ id?: string name: string - type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' + type: + | 'string' + | 'number' + | 'currency' + | 'boolean' + | 'date' + | 'ttl' + | 'json' + | 'select' + | 'reference' required: boolean unique: boolean workflowGroupId?: string @@ -8795,6 +8952,7 @@ type UpdateWorkflowGroupResponseRef1 = { }> multiple?: boolean currencyCode?: string + referenceTableId?: string }> } diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 9e5126a982c..6603d5a7627 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -851,6 +851,14 @@ function diffSchemaFields( const zodNames = docPropertyNames(zodObj, zodRoot) const docNames = docPropertyNames(docObj, docRoot) if (!zodNames || !docNames) return + const omittedProperties = Array.isArray((zodObj as Json).omitPropertiesFromOpenApi) + ? new Set( + ((zodObj as Json).omitPropertiesFromOpenApi as unknown[]).filter( + (property): property is string => typeof property === 'string' + ) + ) + : new Set() + const visibleZodNames = new Set([...zodNames].filter((name) => !omittedProperties.has(name))) const fieldPath = (n: string) => (prefix ? `${prefix}.${n}` : n) /** * A `.passthrough()` contract deliberately under-declares its fields, so the @@ -859,7 +867,7 @@ function diffSchemaFields( const extra = (zodObj as Json).additionalProperties const zodIsPassthrough = extra === true || (!!extra && typeof extra === 'object' && Object.keys(extra).length === 0) - for (const n of zodNames) { + for (const n of visibleZodNames) { if (!docNames.has(n)) { fail( ctx.specFile, @@ -868,14 +876,14 @@ function diffSchemaFields( } } for (const n of docNames) { - if (!zodNames.has(n) && !zodIsPassthrough) { + if (!visibleZodNames.has(n) && !zodIsPassthrough) { fail( ctx.specFile, `${ctx.label}: documented ${ctx.where} field "${fieldPath(n)}" does not exist on ${ctx.name}` ) } } - for (const n of zodNames) { + for (const n of visibleZodNames) { if (!docNames.has(n)) continue diffSchemaFields( propertyNode(zodObj, zodRoot, n), diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index c7c3b510584..ba5319fb72c 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -280,7 +280,11 @@ describe('generated OpenAPI documents', () => { }) it('omits feature-flagged table column types', () => { - expect(JSON.stringify(generatedDocument(tablesOpenApiDocument))).not.toContain('"ttl"') + const serializedDocument = JSON.stringify(generatedDocument(tablesOpenApiDocument)) + + expect(serializedDocument).not.toContain('"ttl"') + expect(serializedDocument).not.toContain('"reference"') + expect(serializedDocument).not.toContain('"referenceTableId"') }) it('keeps billing as its own API reference group', () => { diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index 14fbd811396..d10335aee76 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -223,6 +223,45 @@ describe('OpenAPI generator', () => { expect(documentedColumnType).not.toHaveProperty('omitEnumValuesFromOpenApi') }) + it('omits feature-flagged properties from generated schemas', () => { + const body = z + .object({ + name: z.string().describe('Visible name.'), + unreleasedSetting: z.string().optional().describe('Unreleased setting.'), + }) + .meta({ + id: 'HiddenPropertyRequest', + title: 'Hidden property request', + description: 'Request body.', + omitPropertiesFromOpenApi: ['unreleasedSetting'], + }) + const response = z.object({ ok: z.boolean().describe('Whether the request succeeded.') }).meta({ + id: 'HiddenPropertyResponse', + title: 'Hidden property response', + description: 'Response.', + }) + const contract = defineRouteContract({ + method: 'POST', + path: '/hidden-property', + body, + response: { mode: 'json', schema: response }, + }) + const route = defineOpenApiRoute( + contract, + operation('hiddenProperty', { description: 'Response.' }), + { body, response } + ) + const spec = generateOpenApiDocument(document([route])) + const schemas = (spec.components as JsonObject).schemas as JsonObject + const documentedBody = schemas.HiddenPropertyRequest as JsonObject + const requestProperties = documentedBody.properties as JsonObject + + expect(body.safeParse({ name: 'Example', unreleasedSetting: 'enabled' }).success).toBe(true) + expect(requestProperties).toHaveProperty('name') + expect(requestProperties).not.toHaveProperty('unreleasedSetting') + expect(documentedBody).not.toHaveProperty('omitPropertiesFromOpenApi') + }) + it('handles every route response mode and media type', () => { const emptyContract = defineRouteContract({ method: 'DELETE', diff --git a/scripts/openapi/generator.ts b/scripts/openapi/generator.ts index 93fb3bb762b..6003830b105 100644 --- a/scripts/openapi/generator.ts +++ b/scripts/openapi/generator.ts @@ -130,6 +130,41 @@ function omitEnumValuesFromOpenApi( Reflect.deleteProperty(schema, 'omitEnumValuesFromOpenApi') } +function omitPropertiesFromOpenApi( + metadata: z.core.GlobalMeta | undefined, + schema: JsonObject, + label: string +): void { + const omittedProperties = metadata?.omitPropertiesFromOpenApi + if (omittedProperties === undefined) return + + invariant( + Array.isArray(omittedProperties) && omittedProperties.length > 0, + `${label} omitPropertiesFromOpenApi must be a non-empty array` + ) + invariant( + schema.properties !== undefined && + typeof schema.properties === 'object' && + !Array.isArray(schema.properties), + `${label} omitPropertiesFromOpenApi requires an object schema` + ) + + const properties = schema.properties as JsonObject + for (const property of omittedProperties) { + invariant( + typeof property === 'string' && Object.hasOwn(properties, property), + `${label} omits an object property that does not exist` + ) + Reflect.deleteProperty(properties, property) + } + + if (Array.isArray(schema.required)) { + schema.required = schema.required.filter((property) => !omittedProperties.includes(property)) + if (schema.required.length === 0) Reflect.deleteProperty(schema, 'required') + } + Reflect.deleteProperty(schema, 'omitPropertiesFromOpenApi') +} + function comparableSchema(schema: ApiSchema, io: SchemaIo): unknown { const cached = comparableSchemaCache.get(schema)?.get(io) if (cached) return cached @@ -237,6 +272,7 @@ function generateSchema( const schemaLabel = `${label} at ${path.join('.') || ''}` validateExamples(current, metadata?.examples, io, schemaLabel) omitEnumValuesFromOpenApi(metadata, jsonSchema as JsonObject, schemaLabel) + omitPropertiesFromOpenApi(metadata, jsonSchema as JsonObject, schemaLabel) }, }) as JsonObject const byIo = generatedSchemaCache.get(schema) ?? new Map()