From 705f382c12eb84830235583eff5d81ae99262e9a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:31:35 -0700 Subject: [PATCH] feat(tables): preview referenced rows inline --- .../api/table/[tableId]/columns/route.test.ts | 52 ++- .../app/api/table/[tableId]/columns/route.ts | 4 + .../column-config-sidebar.test.tsx | 32 ++ .../column-config-sidebar.tsx | 25 +- .../column-dropdown/column-dropdown.test.tsx | 28 ++ .../column-dropdown/column-dropdown.tsx | 18 +- .../table-grid/cells/cell-content.test.tsx | 70 +++ .../table-grid/cells/cell-content.tsx | 16 +- .../table-grid/cells/cell-render.test.tsx | 196 ++++++++ .../table-grid/cells/cell-render.tsx | 47 +- .../components/table-grid/data-row.tsx | 56 ++- .../table-grid/reference-row-preview.test.tsx | 428 ++++++++++++++++++ .../table-grid/reference-row-preview.tsx | 261 +++++++++++ .../components/table-grid/table-grid.tsx | 275 ++++++++--- .../[tableId]/components/table-grid/types.ts | 9 + .../components/table-grid/utils.test.ts | 143 ++++++ .../[tableId]/components/table-grid/utils.ts | 59 ++- .../[workspaceId]/tables/[tableId]/table.tsx | 6 + .../lib/copy/copy-resources.test.ts | 414 +++++++++++++++++ .../lib/copy/copy-resources.ts | 209 ++++++++- .../lib/promote/copy-unmapped.test.ts | 8 +- .../lib/promote/copy-unmapped.ts | 1 + .../lib/remap/remap-table-groups.ts | 12 + apps/sim/hooks/queries/tables.test.ts | 281 +++++++++++- apps/sim/hooks/queries/tables.ts | 150 +++++- apps/sim/hooks/queries/utils/table-keys.ts | 4 + apps/sim/lib/api/contracts/tables.ts | 2 + apps/sim/lib/api/contracts/workspaces.ts | 2 + .../lib/copilot/generated/tool-catalog-v1.ts | 168 ++++++- .../lib/copilot/generated/tool-schemas-v1.ts | 180 +++++++- apps/sim/lib/core/config/env.ts | 1 + .../sim/lib/core/config/feature-flags.test.ts | 23 + apps/sim/lib/core/config/feature-flags.ts | 8 + apps/sim/lib/folders/bulk.test.ts | 1 + apps/sim/lib/folders/bulk.ts | 18 +- apps/sim/lib/folders/cascade.test.ts | 53 ++- apps/sim/lib/folders/config.ts | 45 +- apps/sim/lib/table/application/bulk.test.ts | 84 ++++ apps/sim/lib/table/application/bulk.ts | 73 ++- apps/sim/lib/table/column-types/reference.ts | 8 + .../column-types/registry.server.test.ts | 126 +++++- .../lib/table/column-types/registry.server.ts | 146 +++++- .../lib/table/column-types/types.server.ts | 8 + apps/sim/lib/table/column-types/types.ts | 8 + .../table/columns/reference-metadata.test.ts | 54 +++ apps/sim/lib/table/columns/service.ts | 5 + apps/sim/lib/table/import.ts | 2 - .../table/reference-columns/availability.ts | 17 + apps/sim/lib/table/service.test.ts | 167 ++++++- apps/sim/lib/table/service.ts | 101 +++-- apps/sim/lib/workspaces/host-context.test.ts | 8 + apps/sim/lib/workspaces/host-context.ts | 5 +- 52 files changed, 3882 insertions(+), 235 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx create mode 100644 apps/sim/lib/table/reference-columns/availability.ts 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 index 5745e92881c..94ab85a26e8 100644 --- 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 @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' interface ComboboxOption { label: string value: string + disabled?: boolean } interface ComboboxProps { @@ -16,6 +17,7 @@ interface ComboboxProps { placeholder?: string searchable?: boolean searchPlaceholder?: string + disabled?: boolean onChange?: (value: string) => void } @@ -143,6 +145,7 @@ describe('ColumnConfigSidebar', () => { existingColumn={null} workspaceId='workspace-1' tableId='table-current' + referenceColumnsEnabled /> ) }) @@ -179,6 +182,7 @@ describe('ColumnConfigSidebar', () => { existingColumn={null} workspaceId='workspace-1' tableId='table-current' + referenceColumnsEnabled /> ) }) @@ -206,6 +210,7 @@ describe('ColumnConfigSidebar', () => { workspaceId='workspace-1' tableId='table-current' onColumnRename={onColumnRename} + referenceColumnsEnabled /> ) }) @@ -227,6 +232,32 @@ describe('ColumnConfigSidebar', () => { expect(onColumnRename).toHaveBeenCalledWith('col-reference', 'Renamed relation') }) + it('keeps an existing Reference column readable 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( @@ -241,6 +272,7 @@ describe('ColumnConfigSidebar', () => { }} workspaceId='workspace-1' tableId='table-current' + referenceColumnsEnabled /> ) }) 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 b33f950b233..91f1521e5ec 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 @@ -60,6 +60,7 @@ interface ColumnConfigSidebarProps { /** Notify parent of a rename so it can rewrite local `columnOrder` / * `columnWidths` keys that reference the old name. */ onColumnRename?: (oldName: string, newName: string) => void + referenceColumnsEnabled: boolean } /** @@ -110,6 +111,7 @@ function ColumnConfigBody({ workspaceId, tableId, onColumnRename, + referenceColumnsEnabled, }: ColumnConfigBodyProps) { const updateColumn = useUpdateColumn({ workspaceId, tableId }) const addColumn = useAddTableColumn({ workspaceId, tableId }) @@ -142,14 +144,20 @@ function ColumnConfigBody({ 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, + enabled: wantsReference && referenceColumnsEnabled, }) const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name })) const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() })) @@ -304,12 +312,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'])} @@ -372,6 +388,7 @@ function ColumnConfigBody({ { setReferenceTableInput(value) if (referenceTableError) setReferenceTableError(null) 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..8557cd38c68 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,31 @@ 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/table-grid/cells/cell-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx new file mode 100644 index 00000000000..67d97748a1f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx @@ -0,0 +1,70 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createTableColumn } from '@sim/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render', + () => ({ + resolveCellRender: () => ({ kind: 'empty' }), + CellRender: () => null, + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors', + () => ({ InlineEditor: () => }) +) + +import { CellContent } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content' + +const COLUMN: DisplayColumn = { + ...createTableColumn({ id: 'col-name', name: 'Name', type: 'string' }), + key: 'col-name', + groupSize: 1, + groupStartColIndex: 0, + headerLabel: 'Name', + isGroupStart: true, +} + +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() +}) + +describe('CellContent', () => { + it('keeps the inline editor below the sticky table header', () => { + act(() => { + root.render( + + ) + }) + + const editorLayer = container.querySelector('[data-testid="inline-editor"]')?.parentElement + expect(editorLayer?.className).toContain('z-[9]') + expect(editorLayer?.className).not.toContain('z-10') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx index bdb533d9773..967f3d873ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx @@ -1,10 +1,14 @@ 'use client' import type { RowExecutionMetadata } from '@/lib/table' +import { + CellRender, + type ReferenceCellAction, + resolveCellRender, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render' import type { TimezoneState } from '@/hooks/queries/general-settings' import type { SaveReason } from '../../../types' import type { DisplayColumn } from '../types' -import { CellRender, resolveCellRender } from './cell-render' import { InlineEditor } from './inline-editors' interface CellContentProps { @@ -16,6 +20,7 @@ interface CellContentProps { workspaceId: string timeZone: string timezoneStatus: TimezoneState['status'] + referenceColumnsEnabled?: boolean isEditing: boolean initialCharacter?: string | null onSave: (value: unknown, reason: SaveReason) => void @@ -28,6 +33,8 @@ interface CellContentProps { waitingOnLabels?: string[] /** Column is an enrichment output — a completed-but-empty cell renders "Not found". */ isEnrichmentOutput?: boolean + /** Opens the inline row preview for a populated Reference cell. */ + referenceAction?: ReferenceCellAction } /** @@ -43,12 +50,14 @@ export function CellContent({ workspaceId, timeZone, timezoneStatus, + referenceColumnsEnabled = true, isEditing, initialCharacter, onSave, onCancel, waitingOnLabels, isEnrichmentOutput, + referenceAction, }: CellContentProps) { const kind = resolveCellRender({ value, @@ -59,12 +68,13 @@ export function CellContent({ currentWorkspaceId: workspaceId, timeZone, timezoneStatus, + referenceColumnsEnabled, }) return ( <> {isEditing && ( -
+
)} - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx new file mode 100644 index 00000000000..096e2669faa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx @@ -0,0 +1,196 @@ +/** + * @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 { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' + +vi.mock('@sim/emcn', () => ({ + Badge: ({ children }: { children: React.ReactNode }) => {children}, + Button: ({ + children, + size, + variant, + ...props + }: React.ButtonHTMLAttributes & { + size?: string + variant?: string + }) => ( + + ), + Checkbox: () => null, + ChipTag: ({ + children, + variant, + ...props + }: React.HTMLAttributes & { variant?: string }) => ( + + {children} + + ), + cn: (...values: Array) => values.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: { children: React.ReactNode }) => children, + Trigger: ({ children }: { children: React.ReactNode }) => children, + Content: ({ children }: { children: React.ReactNode }) => children, + }, +})) + +vi.mock('@/app/workspace/[workspaceId]/logs/utils', () => ({ + StatusBadge: () => null, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell', + () => ({ SimResourceCell: () => null }) +) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({ + resolveSelectOptions: () => [], + SelectPill: () => null, +})) + +import { + CellRender, + resolveCellRender, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render' + +const REFERENCE_COLUMN: DisplayColumn = { + id: 'col-account', + key: 'col-account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + referenceTableName: 'Accounts', + groupSize: 1, + groupStartColIndex: 0, + headerLabel: 'Account', + isGroupStart: true, +} + +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() +}) + +describe('reference cell rendering', () => { + it('resolves a stored row ID to a chip labeled with the referenced table name', () => { + expect( + resolveCellRender({ + value: 'row-account-1', + exec: undefined, + column: REFERENCE_COLUMN, + waitingOnLabels: undefined, + }) + ).toEqual({ kind: 'column-chip', label: 'Accounts' }) + }) + + it('keeps an empty reference cell empty', () => { + expect( + resolveCellRender({ + value: '', + exec: undefined, + column: REFERENCE_COLUMN, + waitingOnLabels: undefined, + }) + ).toEqual({ kind: 'empty' }) + }) + + it('uses a neutral label while the referenced table name is unavailable', () => { + expect( + resolveCellRender({ + value: 'row-account-1', + exec: undefined, + column: { ...REFERENCE_COLUMN, referenceTableName: undefined }, + waitingOnLabels: undefined, + }) + ).toEqual({ kind: 'column-chip', label: 'Referenced table' }) + }) + + it('renders the stored row ID as plain text when the feature is disabled', () => { + expect( + resolveCellRender({ + value: 'row-account-1', + exec: undefined, + column: REFERENCE_COLUMN, + waitingOnLabels: undefined, + referenceColumnsEnabled: false, + }) + ).toEqual({ kind: 'text', text: 'row-account-1' }) + }) + + it('opens the referenced row from the chip without exposing its stored row ID', () => { + const onReferenceClick = vi.fn() + + act(() => { + root.render( + + ) + }) + + const chip = container.querySelector('button') + expect(chip?.textContent).toBe('Accounts') + expect(chip?.dataset.variant).toBe('ghost') + expect(chip?.dataset.size).toBe('sm') + expect(chip?.className).toContain('max-w-full') + expect(chip?.className).toContain('p-0') + expect(chip?.querySelector('svg')).toBeNull() + const tag = chip?.querySelector('[data-chip-tag-variant="field"]') + expect(tag?.textContent).toBe('Accounts') + expect(tag?.className).toContain('min-w-0') + expect(tag?.className).toContain('max-w-full') + + act(() => chip?.click()) + + expect(onReferenceClick).toHaveBeenCalledOnce() + expect(container.textContent).not.toContain('row-account-1') + }) + + it('keeps a chip double-click from reaching the reference cell', () => { + const onCellDoubleClick = vi.fn() + + act(() => { + root.render( +
+ +
+ ) + }) + + act(() => { + container + .querySelector('button') + ?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) + }) + + expect(onCellDoubleClick).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 22971e399d0..c8c632dc11f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' -import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn' +import { Badge, Button, Checkbox, ChipTag, cn, Tooltip } from '@sim/emcn' import { parse } from 'tldts' import { faviconUrl } from '@/lib/core/utils/favicon' import type { RowExecutionMetadata, SelectOption } from '@/lib/table' @@ -29,6 +29,7 @@ export type CellRenderKind = // Plain typed cells | { kind: 'boolean'; checked: boolean } | { kind: 'select'; options: SelectOption[] } + | { kind: 'column-chip'; label: string } | { kind: 'json'; text: string } | { kind: 'date'; text: string; raw?: boolean } | { kind: 'url'; text: string; href: string; domain: string } @@ -58,6 +59,7 @@ interface ResolveCellRenderInput { timeZone?: string /** Invalid or unavailable preferences render time-based values without conversion. */ timezoneStatus?: TimezoneState['status'] + referenceColumnsEnabled?: boolean } export function resolveCellRender({ @@ -69,6 +71,7 @@ export function resolveCellRender({ currentWorkspaceId, timeZone, timezoneStatus, + referenceColumnsEnabled = true, }: ResolveCellRenderInput): CellRenderKind { const isNull = value === null || value === undefined const isEmpty = isNull || value === '' @@ -135,6 +138,16 @@ export function resolveCellRender({ if (column.type === 'select') { return { kind: 'select', options: resolveSelectOptions(column, value) } } + const typeDefinition = columnTypeOf(column) + if (referenceColumnsEnabled && typeDefinition.referencePreview) { + const rowId = typeDefinition.referencePreview.getRowId(value) + return rowId + ? { + kind: 'column-chip', + label: column.referenceTableName ?? 'Referenced table', + } + : { kind: 'empty' } + } if (isNull) return { kind: 'empty' } // Formatted here rather than in a render branch because the symbol and // fraction digits come from the COLUMN's currency, which the render switch @@ -264,9 +277,19 @@ function extractSimResourceInfo( interface CellRenderProps { kind: CellRenderKind isEditing: boolean + referenceAction?: ReferenceCellAction +} + +export interface ReferenceCellAction { + expanded: boolean + onClick: () => void } -export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null { +export function CellRender({ + kind, + isEditing, + referenceAction, +}: CellRenderProps): React.ReactElement | null { const valueText = kind.kind === 'value' ? kind.text : null const revealedValueText = useTypewriter(valueText) @@ -388,6 +411,26 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) + case 'column-chip': + return ( + + ) + case 'json': return ( + expandedReference: ReferencePreviewTarget | null + onReferenceClick: (target: ReferencePreviewTarget) => void } function cellRangeRowChanged( @@ -121,6 +132,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.workspaceId !== next.workspaceId || prev.timeZone !== next.timeZone || prev.timezoneStatus !== next.timezoneStatus || + prev.referenceColumnsEnabled !== next.referenceColumnsEnabled || prev.rowIndex !== next.rowIndex || prev.isFirstRow !== next.isFirstRow || prev.editingColumnName !== next.editingColumnName || @@ -145,7 +157,9 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.activeDispatches !== next.activeDispatches || prev.pinnedOffsets !== next.pinnedOffsets || prev.lastPinnedColKey !== next.lastPinnedColKey || - prev.findMatchColumns !== next.findMatchColumns + prev.findMatchColumns !== next.findMatchColumns || + prev.expandedReference !== next.expandedReference || + prev.onReferenceClick !== next.onReferenceClick ) { return false } @@ -170,6 +184,7 @@ export const DataRow = React.memo(function DataRow({ workspaceId, timeZone, timezoneStatus, + referenceColumnsEnabled, rowIndex, isFirstRow, editingColumnName, @@ -197,6 +212,8 @@ export const DataRow = React.memo(function DataRow({ pinnedOffsets, lastPinnedColKey, findMatchColumns, + expandedReference, + onReferenceClick, }: DataRowProps) { const sel = normalizedSelection /** @@ -310,6 +327,24 @@ export const DataRow = React.memo(function DataRow({
{columns.map((column, colIndex) => { + const value = + pendingCellValue && column.key in pendingCellValue + ? pendingCellValue[column.key] + : row.data[column.key] + const referencePreview = referenceColumnsEnabled + ? columnTypeOf(column).referencePreview + : undefined + const referenceRowId = referencePreview?.getRowId(value) ?? null + const referenceTableId = referencePreview?.getTableId(column) + const referenceTarget = + referenceTableId && referenceRowId + ? { + sourceRowId: row.id, + sourceColumnKey: column.key, + referenceTableId, + referenceRowId, + } + : null const inRange = sel !== null && rowIndex >= sel.startRow && @@ -407,11 +442,8 @@ export const DataRow = React.memo(function DataRow({ workspaceId={workspaceId} timeZone={timeZone} timezoneStatus={timezoneStatus} - value={ - pendingCellValue && column.key in pendingCellValue - ? pendingCellValue[column.key] - : row.data[column.key] - } + referenceColumnsEnabled={referenceColumnsEnabled} + value={value} exec={resolveCellExec( row, column.workflowGroupId @@ -435,6 +467,14 @@ export const DataRow = React.memo(function DataRow({ 'enrichment' : false } + referenceAction={ + referenceTarget + ? { + expanded: isSameReferencePreviewTarget(expandedReference, referenceTarget), + onClick: () => onReferenceClick(referenceTarget), + } + : undefined + } /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx new file mode 100644 index 00000000000..7ac111d6613 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx @@ -0,0 +1,428 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createTableColumn, createTableDefinition, createTableRow } from '@sim/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { previewQuery } = vi.hoisted(() => ({ + previewQuery: { + data: undefined as ReturnType | null | undefined, + isError: false, + }, +})) + +vi.mock('@/lib/table/column-types', () => ({ + columnTypeById: () => ({ icon: () => null }), +})) + +vi.mock('@sim/emcn/icons', () => ({ + Loader: () => null, + SquareArrowUpRight: () => , +})) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells', () => ({ + CellContent: ({ column, value }: { column: { referenceTableName?: string }; value: unknown }) => ( + {String(value)} + ), +})) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon', + () => ({ ColumnTypeIcon: () => null }) +) + +import { + REFERENCE_ROW_PREVIEW_HEIGHT, + ReferenceRowPreview, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview' + +let container: HTMLDivElement +let root: Root +let previewTable: ReturnType | undefined +let previewTableStatus: 'error' | 'ready' +const REFERENCE_TABLE_NAMES = new Map([ + ['table-accounts', 'Accounts'], + ['table-owners', 'Owners'], +]) + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const columns = [ + createTableColumn({ id: 'col-name', name: 'Name', type: 'string' }), + createTableColumn({ id: 'col-tier', name: 'Tier', type: 'string' }), + ] + previewTable = createTableDefinition({ + id: 'table-accounts', + name: 'Accounts', + columns, + }) + previewQuery.data = createTableRow({ + id: 'row-account-1', + data: { 'col-name': 'Acme', 'col-tier': 'Enterprise' }, + }) + previewQuery.isError = false + previewTableStatus = 'ready' + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function renderPreview() { + const preview = ( + + + + +
+ ) + + act(() => { + root.render(
{preview}
) + }) +} + +function horizontalRect(left: number, right: number): DOMRect { + return { + bottom: 0, + height: 0, + left, + right, + top: 0, + width: right - left, + x: left, + y: 0, + toJSON: () => ({}), + } +} + +describe('ReferenceRowPreview', () => { + it('shows the referenced table schema and the matching row inline', () => { + renderPreview() + + expect(container.textContent).toContain('Accounts') + expect(container.textContent).toContain('Name') + expect(container.textContent).toContain('Tier') + expect(container.textContent).toContain('Acme') + expect(container.textContent).toContain('Enterprise') + expect(container.textContent).not.toContain('Open in sub view') + const goToTableLink = container.querySelector('a[aria-label="Go to table"]') + expect(goToTableLink?.getAttribute('href')).toBe('/workspace/workspace-1/tables/table-accounts') + expect(goToTableLink?.getAttribute('title')).toBe('Go to table') + expect(goToTableLink?.className).toContain('size-[20px]') + expect(goToTableLink?.className).toContain('hover-hover:bg-[var(--surface-active)]') + expect(goToTableLink?.parentElement?.className).toContain('h-9') + expect(goToTableLink?.parentElement?.className).toContain('gap-1.5') + expect(goToTableLink?.previousElementSibling?.textContent).toBe('Accounts') + expect(goToTableLink?.previousElementSibling?.className).not.toContain('font-medium') + expect(goToTableLink?.textContent).toBe('') + expect( + goToTableLink?.querySelector('[data-testid="square-arrow-up-right-icon"]') + ).not.toBeNull() + const previewShell = container.querySelector('tbody > tr > td > div > div') + expect(previewShell?.lastElementChild?.className).toContain('h-9') + expect(previewShell?.lastElementChild?.querySelector('a')).toBeNull() + const previewCell = container.querySelector('tbody > tr > td') + expect(previewCell?.className).toContain('overflow-clip') + expect(previewCell?.className).toContain('border-r') + expect(container.querySelector('td > div')?.className).toContain('sticky left-0') + expect(container.querySelector('td > div')?.className).toContain('w-0') + expect(container.querySelector('td > div')?.className).toContain( + `h-[${REFERENCE_ROW_PREVIEW_HEIGHT}px]` + ) + const subtable = container.querySelector('[role="table"]') + expect(subtable?.className).toContain('w-full') + expect(subtable?.className).toContain('h-full') + expect(subtable?.className).not.toContain('cursor-default') + expect(subtable?.className).not.toContain('select-none') + expect(subtable?.className).toContain('grid-rows-2') + expect(subtable?.querySelectorAll('[role="row"]')).toHaveLength(2) + expect(subtable?.querySelectorAll('[role="columnheader"]')).toHaveLength(2) + expect(subtable?.querySelectorAll('[role="cell"]')).toHaveLength(2) + const dataValueWrappers = subtable?.querySelectorAll('[role="cell"] > div') ?? [] + expect( + Array.from(dataValueWrappers).every( + (node) => + node.classList.contains('w-full') && + node.classList.contains('min-w-0') && + node.classList.contains('overflow-clip') + ) + ).toBe(true) + const subtableViewport = container.querySelector('.overscroll-x-contain') + expect(subtableViewport?.className).toContain('overflow-x-auto') + expect(subtableViewport?.className).toContain('overflow-y-hidden') + expect(subtableViewport?.className).toContain('border-y') + expect(container.innerHTML).not.toContain('rounded-md') + }) + + it('passes referenced table names to reference cells in the preview', () => { + const referenceColumn = createTableColumn({ + id: 'col-owner', + name: 'Owner', + }) + Object.assign(referenceColumn, { + type: 'reference', + referenceTableId: 'table-owners', + }) + previewTable = createTableDefinition({ + id: 'table-accounts', + name: 'Accounts', + columns: [referenceColumn], + }) + previewQuery.data = createTableRow({ + id: 'row-account-1', + data: { 'col-owner': 'row-owner-1' }, + }) + + renderPreview() + + const referenceValue = container.querySelector('[data-reference-table-name="Owners"]') + expect(referenceValue?.textContent).toBe('row-owner-1') + }) + + it('scrolls horizontally when wheel input starts on cell text', () => { + renderPreview() + + const subtableViewport = container.querySelector('.overscroll-x-contain') + const cellText = Array.from(container.querySelectorAll('[role="cell"] span')).find( + (element) => element.textContent === 'Acme' + ) + if (!subtableViewport || !cellText) throw new Error('Expected the referenced row preview') + + const wheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaX: 80, + }) + act(() => { + cellText.dispatchEvent(wheelEvent) + }) + + expect(subtableViewport.scrollLeft).toBe(80) + expect(wheelEvent.defaultPrevented).toBe(true) + + const verticalWheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaX: 10, + deltaY: 80, + }) + act(() => { + cellText.dispatchEvent(verticalWheelEvent) + }) + + expect(subtableViewport.scrollLeft).toBe(80) + expect(verticalWheelEvent.defaultPrevented).toBe(false) + }) + + it('sizes the inner scroller to the visible portion of the preview cell', () => { + let previewCellRight = 1_500 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.matches('[data-table-scroll]')) return horizontalRect(100, 920) + if (this.matches('tbody > tr > td')) return horizontalRect(-500, previewCellRight) + return horizontalRect(0, 0) + }) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function () { + return this.matches('[data-table-scroll]') ? 800 : 0 + }) + renderPreview() + + const previewShell = container.querySelector('tbody > tr > td > div > div') + expect(previewShell?.style.getPropertyValue('--reference-preview-width')).toBe('800px') + + previewCellRight = 780 + const scrollRoot = container.querySelector('[data-table-scroll]') + if (!scrollRoot) throw new Error('Expected the table scroll root to be rendered') + scrollRoot.scrollLeft = 120 + act(() => { + scrollRoot.dispatchEvent(new Event('scroll')) + }) + + expect(previewShell?.style.getPropertyValue('--reference-preview-width')).toBe('680px') + }) + + it('updates on resize and releases its observer and scroll listener', () => { + let previewCellRight = 1_500 + let resizeCallback: ResizeObserverCallback | null = null + let resizeObserver: ResizeObserver | null = null + const observe = vi.fn() + const disconnect = vi.fn() + + class MockResizeObserver implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + resizeObserver = this + } + + observe(target: Element, options?: ResizeObserverOptions) { + observe(target, options) + } + + unobserve() {} + + disconnect() { + disconnect() + } + } + + vi.stubGlobal('ResizeObserver', MockResizeObserver) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.matches('[data-table-scroll]')) return horizontalRect(100, 900) + if (this.matches('tbody > tr > td')) return horizontalRect(-500, previewCellRight) + return horizontalRect(0, 0) + }) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function () { + return this.matches('[data-table-scroll]') ? 800 : 0 + }) + const registeredListeners: Array<{ + target: EventTarget + type: string + listener: EventListenerOrEventListenerObject | null + }> = [] + const removedListeners: typeof registeredListeners = [] + const originalAddEventListener = EventTarget.prototype.addEventListener + const originalRemoveEventListener = EventTarget.prototype.removeEventListener + vi.spyOn(EventTarget.prototype, 'addEventListener').mockImplementation( + function (type, listener, options) { + registeredListeners.push({ target: this, type, listener }) + originalAddEventListener.call(this, type, listener, options) + } + ) + vi.spyOn(EventTarget.prototype, 'removeEventListener').mockImplementation( + function (type, listener, options) { + removedListeners.push({ target: this, type, listener }) + originalRemoveEventListener.call(this, type, listener, options) + } + ) + + renderPreview() + + const previewShell = container.querySelector('tbody > tr > td > div > div') + const scrollRoot = container.querySelector('[data-table-scroll]') + const previewCell = container.querySelector('tbody > tr > td') + const previewViewport = container.querySelector('.overscroll-x-contain') + if (!scrollRoot) throw new Error('Expected the table scroll root to be rendered') + if (!previewCell) throw new Error('Expected the preview cell to be rendered') + if (!previewViewport) throw new Error('Expected the preview viewport to be rendered') + const scrollListener = registeredListeners.find( + ({ target, type }) => target === scrollRoot && type === 'scroll' + )?.listener + const wheelListener = registeredListeners.find( + ({ target, type }) => target === previewViewport && type === 'wheel' + )?.listener + if (!scrollListener) throw new Error('Expected the scroll listener to be registered') + if (!wheelListener) throw new Error('Expected the wheel listener to be registered') + expect(observe).toHaveBeenCalledTimes(2) + expect(observe.mock.calls.some(([target]) => target === scrollRoot)).toBe(true) + expect(observe.mock.calls.some(([target]) => target === previewCell)).toBe(true) + + previewCellRight = 780 + if (!resizeCallback || !resizeObserver) { + throw new Error('Expected the resize observer to be initialized') + } + act(() => resizeCallback([], resizeObserver)) + + expect(previewShell?.style.getPropertyValue('--reference-preview-width')).toBe('680px') + + act(() => root.render(null)) + + expect(disconnect).toHaveBeenCalledOnce() + expect(removedListeners).toContainEqual({ + target: scrollRoot, + type: 'scroll', + listener: scrollListener, + }) + expect(removedListeners).toContainEqual({ + target: previewViewport, + type: 'wheel', + listener: wheelListener, + }) + }) + + it('shows no match when the stored row ID does not resolve', () => { + previewQuery.data = null + + renderPreview() + + expect(container.textContent).toContain('No matching row') + }) + + it('keeps non-404 failures distinct from missing rows', () => { + previewQuery.isError = true + + renderPreview() + + expect(container.textContent).toContain("Couldn't load referenced row") + expect(container.textContent).not.toContain('No matching row') + }) + + it('shows an empty-schema state when the referenced table has no columns', () => { + if (!previewTable) throw new Error('Expected the referenced table fixture') + previewTable.schema.columns = [] + + renderPreview() + + expect(container.textContent).toContain('This table has no columns') + }) + + it('shows a terminal error when table metadata fails to load', () => { + previewTable = undefined + previewTableStatus = 'error' + + renderPreview() + + expect(container.textContent).toContain("Couldn't load referenced table") + expect(container.textContent).not.toContain('Loading referenced table') + }) + + it('shows a terminal unavailable state when prefetched metadata has no table', () => { + previewTable = undefined + previewTableStatus = 'ready' + + renderPreview() + + expect(container.textContent).toContain('Referenced table unavailable') + expect(container.textContent).not.toContain('Loading referenced table') + }) + + it('preserves row errors for an empty schema', () => { + if (!previewTable) throw new Error('Expected the referenced table fixture') + previewTable.schema.columns = [] + previewQuery.data = undefined + previewQuery.isError = true + + renderPreview() + expect(container.textContent).toContain("Couldn't load referenced row") + }) + + it('preserves a missing row for an empty schema', () => { + if (!previewTable) throw new Error('Expected the referenced table fixture') + previewTable.schema.columns = [] + previewQuery.data = null + + renderPreview() + expect(container.textContent).toContain('No matching row') + expect(container.textContent).not.toContain('This table has no columns') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx new file mode 100644 index 00000000000..fbefe076426 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx @@ -0,0 +1,261 @@ +'use client' + +import { memo, type ReactNode, useLayoutEffect, useMemo, useRef } from 'react' +import { buttonVariants } from '@sim/emcn' +import { SquareArrowUpRight } from '@sim/emcn/icons' +import { noop } from '@sim/utils/helpers' +import Link from 'next/link' +import type { TableDefinition } from '@/lib/table' +import { columnTypeById } from '@/lib/table/column-types' +import { CellContent } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells' +import { ColumnTypeIcon } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon' +import { + expandToDisplayColumns, + type ReferenceTableLoadStatus, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils' +import type { TimezoneState } from '@/hooks/queries/general-settings' +import type { useReferenceRowPreview } from '@/hooks/queries/tables' + +/** + * Must match the sticky anchor's `h-[144px]` class below because the row + * virtualizer reserves this exact height. The zero-width anchor stays sticky + * across the full table width without JavaScript-driven positioning. + */ +export const REFERENCE_ROW_PREVIEW_HEIGHT = 144 + +const ReferenceIcon = columnTypeById('reference').icon + +interface ReferenceRowPreviewProps { + workspaceId: string + timeZone: string + timezoneStatus: TimezoneState['status'] + referenceTableId: string + table: TableDefinition | undefined + tableStatus: Exclude + referenceTableNames: ReadonlyMap + colSpan: number + row: NonNullable['data']>['row'] | undefined + rowError: boolean +} + +export const ReferenceRowPreview = memo(function ReferenceRowPreview({ + workspaceId, + timeZone, + timezoneStatus, + referenceTableId, + table, + tableStatus, + referenceTableNames, + colSpan, + row, + rowError, +}: ReferenceRowPreviewProps) { + const previewCellRef = useRef(null) + const previewShellRef = useRef(null) + const previewViewportRef = useRef(null) + const columns = useMemo( + () => expandToDisplayColumns(table?.schema.columns ?? [], [], referenceTableNames), + [table?.schema.columns, referenceTableNames] + ) + + useLayoutEffect(() => { + const previewCell = previewCellRef.current + const previewShell = previewShellRef.current + const scrollRoot = previewCell?.closest('[data-table-scroll]') + if (!previewCell || !previewShell || !scrollRoot) return + + let previousWidth: number | null = null + let previousScrollLeft = scrollRoot.scrollLeft + + const updateWidth = () => { + const cellBounds = previewCell.getBoundingClientRect() + const viewportBounds = scrollRoot.getBoundingClientRect() + const viewportLeft = viewportBounds.left + scrollRoot.clientLeft + const viewportRight = viewportLeft + scrollRoot.clientWidth + const visibleLeft = Math.max(cellBounds.left, viewportLeft) + const visibleRight = Math.min(cellBounds.right, viewportRight) + const width = Math.max(0, visibleRight - visibleLeft) + if (width === previousWidth) return + previousWidth = width + previewShell.style.setProperty('--reference-preview-width', `${width}px`) + } + + const handleScroll = () => { + if (scrollRoot.scrollLeft === previousScrollLeft) return + previousScrollLeft = scrollRoot.scrollLeft + updateWidth() + } + + updateWidth() + scrollRoot.addEventListener('scroll', handleScroll, { passive: true }) + + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateWidth) + resizeObserver?.observe(scrollRoot) + resizeObserver?.observe(previewCell) + + return () => { + scrollRoot.removeEventListener('scroll', handleScroll) + resizeObserver?.disconnect() + } + }, []) + + useLayoutEffect(() => { + const previewViewport = previewViewportRef.current + if (!previewViewport) return + + const handleWheel = (event: WheelEvent) => { + if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return + event.preventDefault() + previewViewport.scrollLeft += event.deltaX + } + + previewViewport.addEventListener('wheel', handleWheel, { passive: false }) + return () => previewViewport.removeEventListener('wheel', handleWheel) + }, []) + + let content: ReactNode + if (tableStatus === 'error') { + content = ( +
+ Couldn't load referenced table +
+ ) + } else if (!table) { + content = ( +
+ Referenced table unavailable +
+ ) + } else if (columns.length === 0 && rowError) { + content = ( +
+ Couldn't load referenced row +
+ ) + } else if (columns.length === 0 && !row) { + content = ( +
+ No matching row +
+ ) + } else if (columns.length === 0) { + content = ( +
+ This table has no columns +
+ ) + } else { + content = ( +
+
+ {columns.map((column) => ( +
+ + + {column.name} + +
+ ))} +
+
+
+ {rowError ? ( +
+ Couldn't load referenced row +
+ ) : !row ? ( +
+ No matching row +
+ ) : ( + <> + {columns.map((column) => ( +
+
+ +
+
+ ))} +
+ + )} +
+
+ ) + } + + return ( + + +
+
+
+ {table ? ( + <> + + {table.name} + + + + + ) : ( + <> + + Table unavailable + + )} +
+ +
+ {content} +
+ +
+
+
+ + + ) +}) 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 4ee4244edee..ea36c125d59 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 @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn, toast, useToast } from '@sim/emcn' import { Loader, TableX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -31,6 +31,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 { + REFERENCE_ROW_PREVIEW_HEIGHT, + ReferenceRowPreview, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview' 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' @@ -43,6 +47,8 @@ import { useDeleteColumn, useDeleteWorkflowGroup, useFindTableRows, + useReferenceRowPreview, + useReferenceTableMetadata, useTableRunState, useUpdateColumn, useUpdateTableMetadata, @@ -68,7 +74,7 @@ import { ColumnHeaderMenu, WorkflowGroupMetaCell } from './headers' import { RemoteSelectionOverlay } from './remote-selection-overlay' import { exceedsTablePasteRowLimit, parseBoundedTsv } from './table-paste' import { AddRowButton, SelectAllCheckbox, TableColGroup } from './table-primitives' -import type { DisplayColumn } from './types' +import type { DisplayColumn, ReferencePreviewTarget } from './types' import { buildHeaderGroups, buildTableSelectionContext, @@ -77,6 +83,7 @@ import { checkboxColLayout, chipRowCount, classifyExecStatusMix, + collectReferenceTableIds, collectRowSnapshots, computeNormalizedSelection, drainTargetForChip, @@ -84,10 +91,13 @@ import { expandToDisplayColumns, horizontalEdgeScrollVelocity, isCellInSelection, + isSameReferencePreviewTarget, moveCell, + type ReferenceTableLoadStatus, ROW_SELECTION_ALL, ROW_SELECTION_NONE, type RowSelection, + resolveDisplayedReferencePreviewTarget, rowSelectionCoversAll, rowSelectionIncludes, rowSelectionIsEmpty, @@ -172,6 +182,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 +447,7 @@ async function chunkBatchUpdates( export function TableGrid({ workspaceId: propWorkspaceId, tableId: propTableId, + referenceColumnsEnabled, embedded, tableRowTtlEnabled, remoteSelections, @@ -598,6 +610,39 @@ export function TableGrid({ // (and one the server rejects outright). filter: effectiveFilter, } = useTable({ workspaceId, tableId, queryOptions }) + const referenceTableIds = useMemo( + () => (referenceColumnsEnabled ? collectReferenceTableIds(columns) : []), + [columns, referenceColumnsEnabled] + ) + const referenceTableQueries = useReferenceTableMetadata(workspaceId, referenceTableIds) + const directReferenceTables = useMemo( + () => referenceTableQueries.flatMap(({ data }) => (data ? [data] : [])), + [referenceTableQueries] + ) + const nestedReferenceTableIds = useMemo(() => { + const directIds = new Set(referenceTableIds) + return collectReferenceTableIds( + directReferenceTables.flatMap((table) => table.schema.columns) + ).filter((id) => !directIds.has(id)) + }, [directReferenceTables, referenceTableIds]) + const nestedReferenceTableQueries = useReferenceTableMetadata( + workspaceId, + nestedReferenceTableIds + ) + const { referenceTables, referenceTableNames } = useMemo(() => { + const tables = new Map() + const names = new Map() + for (const table of directReferenceTables) { + tables.set(table.id, table) + names.set(table.id, table.name) + } + for (const { data: table } of nestedReferenceTableQueries) { + if (!table) continue + tables.set(table.id, table) + names.set(table.id, table.name) + } + return { referenceTables: tables, referenceTableNames: names } + }, [directReferenceTables, nestedReferenceTableQueries]) /** Sort is single-column, so only the first spec entry can be active. */ const activeSort = queryOptions.sort?.[0] @@ -656,19 +701,7 @@ export function TableGrid({ */ const [headerHeight, setHeaderHeight] = useState(0) const [rowHeight, setRowHeight] = useState(ROW_HEIGHT_ESTIMATE) - - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => rowHeight, - overscan: 12, - scrollMargin: headerHeight, - getItemKey: (index) => rows[index]?.id ?? index, - }) - - useEffect(() => { - rowVirtualizer.measure() - }, [rowHeight, rowVirtualizer]) + const [expandedReference, setExpandedReference] = useState(null) useLayoutEffect(() => { const el = theadRef.current @@ -902,8 +935,84 @@ export function TableGrid({ const hidden = new Set(hiddenColumns) ordered = ordered.filter((col) => !hidden.has(getColumnId(col))) } - return expandToDisplayColumns(ordered, tableWorkflowGroups) - }, [columns, columnOrder, hiddenColumns, tableWorkflowGroups]) + return expandToDisplayColumns(ordered, tableWorkflowGroups, referenceTableNames) + }, [columns, columnOrder, hiddenColumns, tableWorkflowGroups, referenceTableNames]) + + const activeExpandedReference = useMemo(() => { + if (!referenceColumnsEnabled || !expandedReference) return null + const sourceRow = rows.find((row) => row.id === expandedReference.sourceRowId) + const sourceColumn = displayColumns.find( + (column) => column.key === expandedReference.sourceColumnKey + ) + const referencePreview = sourceColumn ? columnTypeOf(sourceColumn).referencePreview : undefined + return sourceRow?.data[expandedReference.sourceColumnKey] === + expandedReference.referenceRowId && + sourceColumn && + referencePreview?.getTableId(sourceColumn) === expandedReference.referenceTableId + ? expandedReference + : null + }, [displayColumns, rows, expandedReference, referenceColumnsEnabled]) + const referencePreviewTable = activeExpandedReference + ? referenceTables.get(activeExpandedReference.referenceTableId) + : undefined + const referencePreviewTableQuery = activeExpandedReference + ? referenceTableQueries[referenceTableIds.indexOf(activeExpandedReference.referenceTableId)] + : undefined + const referencePreviewTableStatus: ReferenceTableLoadStatus = referencePreviewTable + ? 'ready' + : referencePreviewTableQuery?.isError + ? 'error' + : referencePreviewTableQuery?.isSuccess + ? 'ready' + : 'loading' + const referencePreviewQuery = useReferenceRowPreview( + workspaceId, + activeExpandedReference?.referenceTableId, + activeExpandedReference?.referenceRowId, + activeExpandedReference?.sourceRowId, + activeExpandedReference?.sourceColumnKey + ) + const loadedReferencePreviewTarget: ReferencePreviewTarget | null = referencePreviewQuery.data + ? { + sourceRowId: referencePreviewQuery.data.sourceRowId, + sourceColumnKey: referencePreviewQuery.data.sourceColumnKey, + referenceTableId: referencePreviewQuery.data.tableId, + referenceRowId: referencePreviewQuery.data.rowId, + } + : null + const displayedReferencePreview = referenceColumnsEnabled + ? resolveDisplayedReferencePreviewTarget({ + activeTarget: activeExpandedReference, + loadedTarget: loadedReferencePreviewTarget, + isFetching: referencePreviewQuery.isFetching, + isError: referencePreviewQuery.isError, + }) + : null + const expandedSourceRowId = displayedReferencePreview?.sourceRowId ?? null + const displayedReferenceTable = referencePreviewQuery.isError + ? referencePreviewTable + : referencePreviewQuery.data?.table + const displayedReferenceRow = referencePreviewQuery.isError + ? undefined + : referencePreviewQuery.data?.row + const displayedReferenceTableStatus: Exclude = + referencePreviewQuery.isError && referencePreviewTableStatus === 'error' ? 'error' : 'ready' + const displayedReferenceRowError = + referencePreviewQuery.isError && referencePreviewTableStatus !== 'error' + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + rowHeight + (rows[index]?.id === expandedSourceRowId ? REFERENCE_ROW_PREVIEW_HEIGHT : 0), + overscan: 12, + scrollMargin: headerHeight, + getItemKey: (index) => rows[index]?.id ?? index, + }) + + useEffect(() => { + rowVirtualizer.measure() + }, [rowHeight, expandedSourceRowId, rowVirtualizer]) /** Column id → its rendered index (matches the cells' `data-col`), for placing overlays. * Only built when collaborators are present (the overlay it feeds is gated on that too), @@ -2751,6 +2860,14 @@ export function TableGrid({ [] ) + const handleReferenceClick = useCallback((target: ReferencePreviewTarget) => { + setEditingCell(null) + setInitialCharacter(null) + setExpandedReference((current) => + isSameReferencePreviewTarget(current, target) ? null : target + ) + }, []) + const handleCellDoubleClick = useCallback( (rowId: string, columnName: string, columnKey: string) => { const column = columnsRef.current.find((c) => c.key === columnKey) @@ -2867,8 +2984,9 @@ export function TableGrid({ if (!el) return const handleKeyDown = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('input, textarea, select, button, a, [contenteditable]')) return if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) { e.preventDefault() @@ -4702,7 +4820,7 @@ export function TableGrid({ ref={scrollRef} tabIndex={-1} className={cn( - 'min-h-0 flex-1 overflow-auto overscroll-none outline-none', + 'min-h-0 flex-1 overflow-auto overscroll-none outline-none [container-type:inline-size]', resizingColumn && 'select-none' )} data-table-scroll @@ -4904,7 +5022,9 @@ export function TableGrid({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} - onGoToReferenceTable={handleGoToReferenceTable} + onGoToReferenceTable={ + referenceColumnsEnabled ? handleGoToReferenceTable : undefined + } onViewWorkflow={handleViewWorkflow} onSortColumn={onSortColumn} onClearSort={onClearSort} @@ -4924,6 +5044,7 @@ export function TableGrid({ tableRowTtlEnabled={tableRowTtlEnabled} trigger='inline-header' disabled={addColumnMutation.isPending} + referenceColumnsEnabled={referenceColumnsEnabled} blocked={!canMutateSchema} onBlocked={() => onBlockedAction('add-column')} onPickType={handleAddColumnOfType} @@ -4965,50 +5086,76 @@ export function TableGrid({ const index = virtualRow.index const row = rows[index] if (!row) return null + const rowReference = + displayedReferencePreview?.sourceRowId === row.id + ? displayedReferencePreview + : null + const displayedRowReference = + displayedReferencePreview?.sourceRowId === row.id + ? displayedReferencePreview + : null return ( - 0 ? pinnedOffsets : undefined} - lastPinnedColKey={lastPinnedColKey} - findMatchColumns={findMatchColumnsByRowId.get(row.id)} - /> + + 0 ? pinnedOffsets : undefined} + lastPinnedColKey={lastPinnedColKey} + findMatchColumns={findMatchColumnsByRowId.get(row.id)} + expandedReference={rowReference} + onReferenceClick={handleReferenceClick} + /> + {displayedRowReference ? ( + + ) : null} + ) })} {paddingBottom > 0 && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts index af5cceea88c..3b0388e1d70 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts @@ -22,6 +22,8 @@ export interface ColumnSourceInfo { export interface DisplayColumn extends ColumnDefinition { /** Stable per-visual-column identifier (= column.name). */ key: string + /** Display name of the table targeted by a reference column. */ + referenceTableName?: string /** Block id producing this column's value (workflow-output columns only). */ outputBlockId?: string /** Pluck path the workflow ran for this column. */ @@ -35,3 +37,10 @@ export interface DisplayColumn extends ColumnDefinition { /** True when this is the leftmost sibling of its group (or non-grouped). */ isGroupStart: boolean } + +export interface ReferencePreviewTarget { + sourceRowId: string + sourceColumnKey: string + referenceTableId: string + referenceRowId: string +} 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..ed9356165ea 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,8 +12,12 @@ import { buildTableSelectionContext, canWriteRowsWithChip, chipRowCount, + collectReferenceTableIds, drainTargetForChip, + expandToDisplayColumns, horizontalEdgeScrollVelocity, + isSameReferencePreviewTarget, + resolveDisplayedReferencePreviewTarget, selectedColumnIds, } from './utils' @@ -26,6 +30,145 @@ function columns(count: number): DisplayColumn[] { const rowIds = (count: number) => Array.from({ length: count }, (_, i) => `r${i}`) +describe('expandToDisplayColumns', () => { + it('attaches the referenced table name to reference display columns', () => { + const [column] = expandToDisplayColumns( + [ + { + id: 'account-column', + name: 'Account', + type: 'reference', + referenceTableId: 'accounts-table', + }, + ], + [], + new Map([['accounts-table', 'Accounts']]) + ) + + expect(column).toMatchObject({ referenceTableName: 'Accounts' }) + }) +}) + +describe('collectReferenceTableIds', () => { + it('returns each referenced table once in stable order', () => { + expect( + collectReferenceTableIds([ + { id: 'name', name: 'Name', type: 'string' }, + { + id: 'owner', + name: 'Owner', + type: 'reference', + referenceTableId: 'table-owners', + }, + { + id: 'account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + }, + { + id: 'backup-owner', + name: 'Backup owner', + type: 'reference', + referenceTableId: 'table-owners', + }, + ]) + ).toEqual(['table-accounts', 'table-owners']) + }) +}) + +describe('resolveDisplayedReferencePreviewTarget', () => { + const first = { + sourceRowId: 'row-1', + sourceColumnKey: 'account', + referenceTableId: 'table-accounts', + referenceRowId: 'account-1', + } + const second = { + sourceRowId: 'row-2', + sourceColumnKey: 'owner', + referenceTableId: 'table-owners', + referenceRowId: 'owner-1', + } + + it('keeps the completed preview visible while a different target fetches', () => { + expect( + resolveDisplayedReferencePreviewTarget({ + activeTarget: second, + loadedTarget: first, + isFetching: true, + isError: false, + }) + ).toEqual(first) + }) + + it('waits on an initial fetch or same-target refresh', () => { + expect( + resolveDisplayedReferencePreviewTarget({ + activeTarget: first, + loadedTarget: null, + isFetching: true, + isError: false, + }) + ).toBeNull() + expect( + resolveDisplayedReferencePreviewTarget({ + activeTarget: first, + loadedTarget: first, + isFetching: true, + isError: false, + }) + ).toBeNull() + }) + + it('switches atomically on completion and reveals terminal errors', () => { + expect( + resolveDisplayedReferencePreviewTarget({ + activeTarget: second, + loadedTarget: second, + isFetching: false, + isError: false, + }) + ).toEqual(second) + expect( + resolveDisplayedReferencePreviewTarget({ + activeTarget: second, + loadedTarget: first, + isFetching: false, + isError: true, + }) + ).toEqual(second) + }) + + it('closes when there is no active target', () => { + expect( + resolveDisplayedReferencePreviewTarget({ + activeTarget: null, + loadedTarget: first, + isFetching: false, + isError: false, + }) + ).toBeNull() + }) +}) + +describe('isSameReferencePreviewTarget', () => { + const target = { + sourceRowId: 'source-row', + sourceColumnKey: 'account-column', + referenceTableId: 'accounts-table', + referenceRowId: 'account-row', + } + + it('matches only the same source cell and referenced row', () => { + expect(isSameReferencePreviewTarget(target, target)).toBe(true) + expect(isSameReferencePreviewTarget(null, target)).toBe(false) + for (const key of Object.keys(target) as Array) { + expect(isSameReferencePreviewTarget({ ...target, [key]: 'different' }, target)).toBe(false) + } + }) +}) + describe('horizontalEdgeScrollVelocity', () => { const getVelocity = (pointerX: number) => horizontalEdgeScrollVelocity({ 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..626270a8b90 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,11 +12,15 @@ import type { WorkflowGroup, } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' +import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps' +import type { + DisplayColumn, + ReferencePreviewTarget, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' import type { ChatContext } from '@/stores/panel' import type { DeletedRowSnapshot } from '@/stores/table/types' -import type { DisplayColumn } from './types' /** * `all` means "every row matching the active filter" — including rows not yet loaded by the @@ -31,6 +35,50 @@ export type RowSelection = export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } +export type ReferenceTableLoadStatus = 'loading' | 'error' | 'ready' + +export function collectReferenceTableIds(columns: ColumnDefinition[]): string[] { + const ids = new Set() + for (const column of columns) { + const referencePreview = columnTypeOf(column).referencePreview + const tableId = referencePreview?.getTableId(column) + if (tableId) ids.add(tableId) + } + return Array.from(ids).sort() +} + +interface DisplayedReferencePreviewInput { + activeTarget: ReferencePreviewTarget | null + loadedTarget: ReferencePreviewTarget | null + isFetching: boolean + isError: boolean +} + +export function resolveDisplayedReferencePreviewTarget({ + activeTarget, + loadedTarget, + isFetching, + isError, +}: DisplayedReferencePreviewInput): ReferencePreviewTarget | null { + if (!activeTarget) return null + if (isError) return activeTarget + if (!loadedTarget) return null + if (isSameReferencePreviewTarget(activeTarget, loadedTarget) && isFetching) return null + return loadedTarget +} + +export function isSameReferencePreviewTarget( + left: ReferencePreviewTarget | null, + right: ReferencePreviewTarget +): boolean { + return ( + left?.sourceRowId === right.sourceRowId && + left.sourceColumnKey === right.sourceColumnKey && + left.referenceTableId === right.referenceTableId && + left.referenceRowId === right.referenceRowId + ) +} + interface HorizontalEdgeScrollVelocityInput { pointerX: number visibleLeft: number @@ -165,7 +213,8 @@ export type HeaderGroup = */ export function expandToDisplayColumns( columns: ColumnDefinition[], - workflowGroups: WorkflowGroup[] + workflowGroups: WorkflowGroup[], + referenceTableNames?: ReadonlyMap ): DisplayColumn[] { const out: DisplayColumn[] = [] const groupById = new Map(workflowGroups.map((g) => [g.id, g])) @@ -194,6 +243,9 @@ export function expandToDisplayColumns( out.push({ ...child, key: getColumnId(child), + referenceTableName: child.referenceTableId + ? referenceTableNames?.get(child.referenceTableId) + : undefined, outputBlockId: output?.blockId, outputPath: output?.path, groupSize: size, @@ -207,6 +259,9 @@ export function expandToDisplayColumns( out.push({ ...column, key: getColumnId(column), + referenceTableName: column.referenceTableId + ? referenceTableNames?.get(column.referenceTableId) + : undefined, groupSize: 1, groupStartColIndex: out.length, headerLabel: column.name, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index d39a6f474d0..3f90d87d679 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') @@ -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(inserted[0]).toEqual(expect.objectContaining({ secretProvenanceVersion: null })) }) + it('rewrites reference cells to the copied referenced-row identity', async () => { + const updatedAt = new Date('2026-08-05T00:00:00.000Z') + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + row: { + id: 'row-order-1', + tableId: 'src-orders', + workspaceId: 'src-ws', + data: { 'col-account': 'row-account-1' }, + secretProvenanceVersion: null, + updatedAt, + }, + provenance: null, + provenanceIsCurrent: false, + }, + ]) + .mockResolvedValueOnce([ + { + row: { + id: 'row-account-1', + tableId: 'src-accounts', + workspaceId: 'src-ws', + data: { 'col-name': 'Acme' }, + secretProvenanceVersion: null, + updatedAt, + }, + provenance: null, + provenanceIsCurrent: false, + }, + ]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + tables: [ + { + sourceId: 'src-orders', + childId: 'child-orders', + dependsOnChildIds: ['child-accounts'], + referenceColumnTargetTableIds: { 'col-account': 'child-accounts' }, + }, + { sourceId: 'src-accounts', childId: 'child-accounts' }, + ], + }), + requestId: 'test', + }) + + expect(result.failed).toBe(0) + const copiedOrderRows = dbChainMockFns.values.mock.calls[0][0] as Array<{ + data: Record + }> + const copiedAccountRows = dbChainMockFns.values.mock.calls[1][0] as Array<{ id: string }> + expect(copiedOrderRows[0].data['col-account']).toBe(copiedAccountRows[0].id) + }) + it('turns stale tracked table provenance into unknown instead of laundering it', async () => { const rowUpdatedAt = new Date('2026-08-05T00:00:00.000Z') dbChainMockFns.limit.mockResolvedValueOnce([ @@ -260,6 +316,51 @@ describe('copyForkResourceContent', () => { ]) }) + it('fails copied tables whose referenced-table dependency failed to copy', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + row: { + id: 'row-order-1', + tableId: 'src-orders', + workspaceId: 'src-ws', + data: { 'col-account': 'row-account-1' }, + secretProvenanceVersion: null, + updatedAt: new Date('2026-08-05T00:00:00.000Z'), + }, + provenance: null, + provenanceIsCurrent: false, + }, + ]) + .mockRejectedValueOnce(new Error('copy failed')) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + tables: [ + { + sourceId: 'src-orders', + childId: 'child-orders', + dependsOnChildIds: ['child-accounts'], + }, + { sourceId: 'src-accounts', childId: 'child-accounts' }, + ], + }), + requestId: 'test', + }) + + expect(result).toEqual({ + copied: 0, + failed: 2, + failures: [ + { kind: 'table', childId: 'child-accounts' }, + { kind: 'table', childId: 'child-orders' }, + ], + }) + expect(dbChainMockFns.values).toHaveBeenCalledWith([ + expect.objectContaining({ tableId: 'child-orders' }), + ]) + }) + it('#1 binds a copied KB document blob to the CHILD workspace + initiating user', async () => { dbChainMockFns.limit .mockResolvedValueOnce([sourceDoc]) @@ -1212,6 +1313,319 @@ describe('copyForkResourceContent', () => { }) describe('copyForkResourceContainers table views', () => { + it('rejects a mapped referenced table when row mappings are unavailable', async () => { + const now = new Date('2026-08-19T00:00:00.000Z') + const selectedDefinition = { + id: 'table-orders', + workspaceId: 'src-ws', + folderId: null, + name: 'Orders', + description: null, + schema: { + columns: [ + { + id: 'col-account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + }, + ], + }, + metadata: {}, + maxRows: 10000, + rowCount: 1, + rowsVersion: 1, + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + archivedAt: null, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + } + const insert = vi.fn() + const tx = { + select: () => ({ + from: () => ({ where: () => Promise.resolve([selectedDefinition]) }), + }), + insert, + } + + await expect( + copyForkResourceContainers({ + tx: tx as unknown as DbOrTx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now, + selection: { + customTools: [], + skills: [], + mcpServers: [], + workflowMcpServers: [], + tables: ['table-orders'], + knowledgeBases: [], + }, + workflowIdMap: new Map(), + resolveMappedTableReference: (sourceTableId) => + sourceTableId === 'table-accounts' ? 'target-accounts' : null, + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + ).rejects.toThrow( + 'Referenced table table-accounts is mapped to target-accounts, but referenced row mappings are unavailable' + ) + expect(insert).not.toHaveBeenCalled() + }) + + it('rejects an unavailable referenced-table dependency before inserting copies', async () => { + const now = new Date('2026-08-19T00:00:00.000Z') + const selectedDefinition = { + id: 'table-orders', + workspaceId: 'src-ws', + folderId: null, + name: 'Orders', + description: null, + schema: { + columns: [ + { + id: 'col-account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + }, + ], + }, + metadata: {}, + maxRows: 10000, + rowCount: 1, + rowsVersion: 1, + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + archivedAt: null, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + } + const insert = vi.fn() + let definitionRead = 0 + const tx = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(definitionRead++ === 0 ? [selectedDefinition] : []), + }), + }), + insert, + } + + await expect( + copyForkResourceContainers({ + tx: tx as unknown as DbOrTx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now, + selection: { + customTools: [], + skills: [], + mcpServers: [], + workflowMcpServers: [], + tables: ['table-orders'], + knowledgeBases: [], + }, + workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + ).rejects.toThrow('Referenced table table-accounts is unavailable for copy') + expect(insert).not.toHaveBeenCalled() + }) + + it('bounds the expanded referenced-table dependency set', async () => { + const tx = { select: vi.fn(), insert: vi.fn() } + + await expect( + copyForkResourceContainers({ + tx: tx as unknown as DbOrTx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date('2026-08-19T00:00:00.000Z'), + selection: { + customTools: [], + skills: [], + mcpServers: [], + workflowMcpServers: [], + tables: Array.from( + { length: MAX_FORK_TABLES_WITH_DEPENDENCIES + 1 }, + (_, index) => `table-${index}` + ), + knowledgeBases: [], + }, + workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + ).rejects.toThrow( + `Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies` + ) + expect(tx.select).not.toHaveBeenCalled() + }) + + it('copies referenced tables transitively and remaps reference columns to their child ids', async () => { + const now = new Date('2026-08-19T00:00:00.000Z') + const definitions = [ + { + id: 'table-orders', + workspaceId: 'src-ws', + folderId: null, + name: 'Orders', + description: null, + schema: { + columns: [ + { + id: 'col-account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + }, + ], + }, + metadata: {}, + maxRows: 10000, + rowCount: 1, + rowsVersion: 1, + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + archivedAt: null, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + }, + { + id: 'table-accounts', + workspaceId: 'src-ws', + folderId: null, + name: 'Accounts', + description: null, + schema: { + columns: [ + { + id: 'col-company', + name: 'Company', + type: 'reference', + referenceTableId: 'table-companies', + }, + ], + }, + metadata: {}, + maxRows: 10000, + rowCount: 1, + rowsVersion: 1, + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + archivedAt: null, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + }, + { + id: 'table-companies', + workspaceId: 'src-ws', + folderId: null, + name: 'Companies', + description: null, + schema: { columns: [{ id: 'col-name', name: 'Name', type: 'string' }] }, + metadata: {}, + maxRows: 10000, + rowCount: 1, + rowsVersion: 1, + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + archivedAt: null, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + }, + ] + const inserted = new Map>>() + let definitionRead = 0 + const tx = { + select: () => ({ + from: (table: unknown) => ({ + where: () => { + if (table === tableViews) return Promise.resolve([]) + if (table !== userTableDefinitions) return Promise.resolve([]) + const rows = [definitions[definitionRead]].filter(Boolean) + definitionRead += 1 + return Promise.resolve(rows) + }, + }), + }), + insert: (table: unknown) => ({ + values: (values: Array>) => { + inserted.set(table, values) + return Promise.resolve() + }, + }), + } + + const result = await copyForkResourceContainers({ + tx: tx as unknown as DbOrTx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now, + selection: { + customTools: [], + skills: [], + mcpServers: [], + workflowMcpServers: [], + tables: ['table-orders'], + knowledgeBases: [], + }, + workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + + const tableMap = result.idMap.get('table') + const childOrdersId = tableMap?.get('table-orders') + const childAccountsId = tableMap?.get('table-accounts') + const childCompaniesId = tableMap?.get('table-companies') + expect(tableMap?.size).toBe(3) + expect(result.names.tables).toEqual(['Orders', 'Accounts', 'Companies']) + expect(result.contentPlan.tables).toEqual([ + { + sourceId: 'table-orders', + childId: childOrdersId, + dependsOnChildIds: [childAccountsId], + referenceColumnTargetTableIds: { 'col-account': childAccountsId }, + }, + { + sourceId: 'table-accounts', + childId: childAccountsId, + dependsOnChildIds: [childCompaniesId], + referenceColumnTargetTableIds: { 'col-company': childCompaniesId }, + }, + { sourceId: 'table-companies', childId: childCompaniesId }, + ]) + + const copiedDefinitions = inserted.get(userTableDefinitions) + expect(copiedDefinitions).toHaveLength(3) + expect( + copiedDefinitions?.find((definition) => definition.id === childOrdersId)?.schema + ).toMatchObject({ columns: [{ referenceTableId: childAccountsId }] }) + expect( + copiedDefinitions?.find((definition) => definition.id === childAccountsId)?.schema + ).toMatchObject({ columns: [{ referenceTableId: childCompaniesId }] }) + }) + it('copies saved views and seeds a default for a legacy table', async () => { const now = new Date('2026-08-19T00:00:00.000Z') const definitions = [ diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index bbb32d2cdff..231f6e34839 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -56,6 +56,8 @@ import { rebindKnowledgeDocumentSecretProvenance, replaceKnowledgeDocumentSecretProvenanceInTx, } from '@/lib/knowledge/secret-provenance' +import { getColumnId } from '@/lib/table/column-keys' +import { collectColumnReferencedTableIds } from '@/lib/table/column-types/registry.server' import { DEFAULT_TABLE_VIEW_NAME } from '@/lib/table/constants' import { nKeysBetween } from '@/lib/table/order-key' import { @@ -91,7 +93,10 @@ import { type ForkReferenceResolver, rewriteEnvRefsInText, } from '@/ee/workspace-forking/lib/remap/remap-references' -import { remapForkTableWorkflowGroups } from '@/ee/workspace-forking/lib/remap/remap-table-groups' +import { + remapForkTableReferences, + remapForkTableWorkflowGroups, +} from '@/ee/workspace-forking/lib/remap/remap-table-groups' const logger = createLogger('WorkspaceForkCopyResources') @@ -100,6 +105,8 @@ const CONTENT_PAGE = 500 const PROVENANCE_CONTENT_PAGE = 8 const MAX_FORK_PROVENANCE_ENTRIES = 10_000 const MAX_FORK_PROVENANCE_BYTES = 8 * 1024 * 1024 +/** Matches the fork contract's per-resource selection ceiling after dependencies are expanded. */ +export const MAX_FORK_TABLES_WITH_DEPENDENCIES = 2_000 function isForkProvenancePageWithinBudget(sidecars: readonly { entries: unknown }[]): boolean { let entries = 0 @@ -217,6 +224,11 @@ export interface CopyResourcesParams { * plan resolver); omitted by fork-create, which preserves env names verbatim (no rewrite). */ resolveEnvName?: (key: string) => string | null | undefined + /** + * Detect whether a referenced source table already maps to a target during promote. Row-level + * mappings do not exist yet, so the copy fails instead of inventing target row identities. + */ + resolveMappedTableReference?: (sourceTableId: string) => string | null | undefined /** * Resolve a source block id to its target block id for copied tables' workflow-group * `outputs[].blockId`. Promote passes the SAME persisted-pair resolver its workflow writes @@ -238,6 +250,13 @@ export interface ForkContentPlanEntry { childId: string } +export interface ForkContentTableEntry extends ForkContentPlanEntry { + /** Copied tables this table's reference columns require to remain available. */ + dependsOnChildIds?: string[] + /** Stable column id to copied target-table id, used to derive copied referenced-row ids. */ + referenceColumnTargetTableIds?: Record +} + /** * A KB to copy post-commit, plus the source-document -> child-document id map for the * documents that were pre-created as placeholders in the transaction (referenced by copied @@ -289,7 +308,7 @@ export interface ForkContentPlan { childWorkspaceId: string /** Initiating user, recorded as the owner of copied KB-document blob bindings in the child. */ userId: string - tables: ForkContentPlanEntry[] + tables: ForkContentTableEntry[] knowledgeBases: ForkContentKbEntry[] skills: ForkContentSkillEntry[] /** Documents copied into an already-existing target KB (sync-only; empty at fork create). */ @@ -360,6 +379,100 @@ function setId(idMap: Map>, type: ForkReso */ type SkillSkeletonInsert = Omit & { content: SQL } +/** Derives the copied row identity without retaining an unbounded source-row map in memory. */ +function deriveCopiedTableRowId(childTableId: string, sourceRowId: string): string { + return `row_${sha256Hex(`table-row:${childTableId}:${sourceRowId}`).slice(0, 32)}` +} + +/** Rewrites reference cells through the same deterministic identity used by copied target rows. */ +function remapCopiedReferenceCells( + data: unknown, + referenceColumnTargetTableEntries: ReadonlyArray | undefined +): unknown { + if (!referenceColumnTargetTableEntries || !isRecordLike(data)) return data + let remapped: Record | undefined + for (const [columnId, childTableId] of referenceColumnTargetTableEntries) { + const sourceRowId = data[columnId] + if (typeof sourceRowId !== 'string' || sourceRowId.length === 0) continue + remapped ??= { ...data } + remapped[columnId] = deriveCopiedTableRowId(childTableId, sourceRowId) + } + return remapped ?? data +} + +/** + * Loads the selected tables plus the transitive closure of tables named by their reference + * columns. Each layer is workspace-scoped and active-only; an unavailable dependency fails the + * copy instead of persisting a source-workspace table id into the child schema. + */ +async function loadTableDefinitionsWithDependencies( + tx: DbOrTx, + sourceWorkspaceId: string, + selectedTableIds: readonly string[], + resolveMappedTableReference?: (sourceTableId: string) => string | null | undefined +): Promise> { + const orderedIds = [...new Set(selectedTableIds)] + if (orderedIds.length > MAX_FORK_TABLES_WITH_DEPENDENCIES) { + throw new Error( + `Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies` + ) + } + const scheduledIds = new Set(orderedIds) + const dependencyIds = new Set() + const definitionsById = new Map() + let pendingIds = [...orderedIds] + + while (pendingIds.length > 0) { + const batchIds = pendingIds + pendingIds = [] + const rows = await tx + .select() + .from(userTableDefinitions) + .where( + and( + inArray(userTableDefinitions.id, batchIds), + eq(userTableDefinitions.workspaceId, sourceWorkspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + + for (const row of rows) { + definitionsById.set(row.id, row) + const referencedIds = collectColumnReferencedTableIds((row.schema as TableSchema).columns) + for (const referencedId of referencedIds) { + dependencyIds.add(referencedId) + if (scheduledIds.has(referencedId)) continue + const mappedTableId = resolveMappedTableReference?.(referencedId) + if (mappedTableId) { + throw new Error( + `Referenced table ${referencedId} is mapped to ${mappedTableId}, but referenced row mappings are unavailable` + ) + } + if (scheduledIds.size >= MAX_FORK_TABLES_WITH_DEPENDENCIES) { + throw new Error( + `Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies` + ) + } + scheduledIds.add(referencedId) + orderedIds.push(referencedId) + pendingIds.push(referencedId) + } + } + + const missingDependencyId = batchIds.find( + (id) => dependencyIds.has(id) && !definitionsById.has(id) + ) + if (missingDependencyId) { + throw new Error(`Referenced table ${missingDependencyId} is unavailable for copy`) + } + } + + return orderedIds.flatMap((id) => { + const definition = definitionsById.get(id) + return definition ? [definition] : [] + }) +} + /** * Copy the selected resources' **container rows** into the child workspace inside * the fork transaction: custom tools, skills, and MCP server configs (each a @@ -628,16 +741,12 @@ export async function copyForkResourceContainers( } if (selection.tables.length > 0) { - const definitions = await tx - .select() - .from(userTableDefinitions) - .where( - and( - inArray(userTableDefinitions.id, selection.tables), - eq(userTableDefinitions.workspaceId, sourceWorkspaceId), - isNull(userTableDefinitions.archivedAt) - ) - ) + const definitions = await loadTableDefinitionsWithDependencies( + tx, + sourceWorkspaceId, + selection.tables, + params.resolveMappedTableReference + ) const sourceViews = definitions.length > 0 ? await tx @@ -672,12 +781,22 @@ export async function copyForkResourceContainers( const inserts: (typeof userTableDefinitions.$inferInsert)[] = [] const viewInserts: (typeof tableViews.$inferInsert)[] = [] + const tableIdMap = new Map( + definitions.map((definition) => [definition.id, generateId()] as const) + ) + for (const [sourceTableId, childTableId] of tableIdMap) { + record('table', sourceTableId, childTableId) + } for (const definition of definitions) { - const childTableId = generateId() - const remappedSchema = remapForkTableWorkflowGroups( - definition.schema as TableSchema, - workflowIdMap, - params.resolveBlockId + const childTableId = tableIdMap.get(definition.id) + if (!childTableId) throw new Error(`Missing copied table identity for ${definition.id}`) + const remappedSchema = remapForkTableReferences( + remapForkTableWorkflowGroups( + definition.schema as TableSchema, + workflowIdMap, + params.resolveBlockId + ), + tableIdMap ) inserts.push({ ...definition, @@ -734,8 +853,27 @@ export async function copyForkResourceContainers( updatedAt: now, }) } - record('table', definition.id, childTableId) - contentPlan.tables.push({ sourceId: definition.id, childId: childTableId }) + const dependsOnChildIds = collectColumnReferencedTableIds( + (definition.schema as TableSchema).columns + ).flatMap((sourceId) => { + const dependencyId = tableIdMap.get(sourceId) + return dependencyId && dependencyId !== childTableId ? [dependencyId] : [] + }) + const referenceColumnTargetTableIds = Object.fromEntries( + (definition.schema as TableSchema).columns.flatMap((column) => { + const [sourceTargetId] = collectColumnReferencedTableIds([column]) + const childTargetId = sourceTargetId ? tableIdMap.get(sourceTargetId) : undefined + return childTargetId ? [[getColumnId(column), childTargetId]] : [] + }) + ) + contentPlan.tables.push({ + sourceId: definition.id, + childId: childTableId, + ...(dependsOnChildIds.length > 0 ? { dependsOnChildIds } : {}), + ...(Object.keys(referenceColumnTargetTableIds).length > 0 + ? { referenceColumnTargetTableIds } + : {}), + }) names.tables.push(definition.name) } if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts) @@ -1197,6 +1335,9 @@ export async function copyForkResourceContent(params: { try { let copied = 0 let afterId: string | null = null + const referenceColumnTargetTableEntries = table.referenceColumnTargetTableIds + ? Object.entries(table.referenceColumnTargetTableIds) + : undefined // `order_key` is nullable, and spreading `...row` would inherit NULLs into a // brand-new tableId that the one-shot backfill script-migration never revisits // (it snapshots the pending set up front) — leaving rows the keyset pager has to @@ -1248,14 +1389,17 @@ export async function copyForkResourceContent(params: { return { row: { ...row, - id: generateId(), + id: deriveCopiedTableRowId(table.childId, row.id), tableId: table.childId, workspaceId: childWorkspaceId, orderKey: row.orderKey ?? mintedKeys[mintedIdx++] ?? null, secretProvenanceVersion: classification.mode === 'legacy' ? null : TABLE_ROW_SECRET_PROVENANCE_VERSION, // Repoint resource-chip URLs in cell data at the child copies (no-op when no maps). - data: contentRefMaps ? remapTableRowResourceUrls(row.data, contentRefMaps) : row.data, + data: remapCopiedReferenceCells( + contentRefMaps ? remapTableRowResourceUrls(row.data, contentRefMaps) : row.data, + referenceColumnTargetTableEntries + ), }, provenance: classification.mode === 'tracked' ? classification : undefined, } @@ -1298,6 +1442,29 @@ export async function copyForkResourceContent(params: { } } + const failedTableIds = new Set( + failures.flatMap((failure) => (failure.kind === 'table' ? [failure.childId] : [])) + ) + let foundFailedDependent = true + while (foundFailedDependent) { + foundFailedDependent = false + for (const table of contentPlan.tables) { + if (failedTableIds.has(table.childId)) continue + if (!table.dependsOnChildIds?.some((dependencyId) => failedTableIds.has(dependencyId))) { + continue + } + failedTableIds.add(table.childId) + failures.push({ kind: 'table', childId: table.childId }) + copiedResources -= 1 + failedResources += 1 + foundFailedDependent = true + logger.warn(`[${requestId}] Failed copied table because a referenced table copy failed`, { + sourceTableId: table.sourceId, + childTableId: table.childId, + }) + } + } + for (const kb of contentPlan.knowledgeBases) { try { await logSkippedConnectorDocuments(kb) diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts index c177ceee97d..b9f8d6b27fe 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts @@ -272,6 +272,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { }) it('threads push orientation through the shared container and mapping boundaries', async () => { + const resolver = vi.fn((kind: ForkRemapKind, sourceId: string) => + kind === 'table' && sourceId === 'mapped-table' ? 'target-table' : null + ) await copyPromoteUnmappedResources({ tx, edge, @@ -290,7 +293,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { }, workflowIdMap: new Map(), folderIdMap: new Map(), - resolver: () => null, + resolver, resolveBlockId, referencedDocumentIds: [], }) @@ -303,6 +306,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { }, }) ) + const containerParams = mockCopyForkResourceContainers.mock.calls.at(-1)?.[0] + expect(containerParams?.resolveMappedTableReference('mapped-table')).toBe('target-table') + expect(resolver).toHaveBeenCalledWith('table', 'mapped-table') expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith( expect.objectContaining({ edgeChildWorkspaceId: 'edge-child', diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts index 19269023429..02e546e917e 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts @@ -232,6 +232,7 @@ export async function copyPromoteUnmappedResources(params: { // A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs // rewritten through the same plan resolver that remaps subblock-value env refs. resolveEnvName: (key) => resolver('env-var', key), + resolveMappedTableReference: (sourceTableId) => resolver('table', sourceTableId), resolveBlockId, documentMappingContext: { edgeChildWorkspaceId: edge.childWorkspaceId, diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts index 592f0565cc7..c8fc6274dcd 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts @@ -1,3 +1,4 @@ +import { remapColumnReferencedTableIds } from '@/lib/table/column-types/registry.server' import type { TableSchema } from '@/lib/table/types' import { deriveForkBlockId, @@ -60,3 +61,14 @@ export function remapForkTableWorkflowGroups( return { ...schema, columns, workflowGroups: remappedGroups } } + +/** Rewrites copied reference columns to the copied target table identities. */ +export function remapForkTableReferences( + schema: TableSchema, + tableIdMap: ReadonlyMap +): TableSchema { + const columns = remapColumnReferencedTableIds(schema.columns, tableIdMap) + return columns.some((column, index) => column !== schema.columns[index]) + ? { ...schema, columns } + : schema +} diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts index 0f81a28108f..5565314bea7 100644 --- a/apps/sim/hooks/queries/tables.test.ts +++ b/apps/sim/hooks/queries/tables.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { useQueries, useQuery } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' const { queryClient, cacheStore } = vi.hoisted(() => { @@ -25,6 +27,7 @@ const { queryClient, cacheStore } = vi.hoisted(() => { .filter(([k]) => k.startsWith(prefix)) .map(([k, v]) => [JSON.parse(k), v]) }), + ensureQueryData: vi.fn(), removeQueries: vi.fn(), }, } @@ -34,6 +37,7 @@ vi.mock('@tanstack/react-query', () => ({ keepPreviousData: {}, infiniteQueryOptions: (opts: unknown) => opts, useQuery: vi.fn(), + useQueries: vi.fn(() => []), useInfiniteQuery: vi.fn(), useQueryClient: vi.fn(() => queryClient), useMutation: vi.fn((options) => options), @@ -57,13 +61,26 @@ vi.mock('@sim/emcn', () => ({ toast: { error: vi.fn(), success: vi.fn() }, })) -import type { TableViewWire } from '@/lib/api/contracts/tables' +import { isApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { + getTableContract, + getTableRowContract, + type TableViewWire, +} from '@/lib/api/contracts/tables' import { tableRowsInfiniteOptions, tableRowsParamsKey, + useBatchUpdateTableRows, useDeleteColumn, + useDeleteTableRow, + useDeleteTableRows, + useReferenceRowPreview, + useReferenceTableMetadata, useRestoreTable, + useTableRow, useUpdateColumn, + useUpdateTableRow, useUpdateTableView, } from '@/hooks/queries/tables' import { tableKeys } from '@/hooks/queries/utils/table-keys' @@ -91,6 +108,268 @@ beforeEach(() => { vi.clearAllMocks() }) +describe('useTableRow', () => { + function getQueryOptions() { + return vi.mocked(useQuery).mock.calls.at(-1)?.[0] as { + enabled: boolean + queryFn: (context: { signal: AbortSignal }) => Promise + } + } + + it('treats a missing referenced row as zero rows', async () => { + vi.mocked(requestJson).mockRejectedValueOnce({ status: 404 }) + vi.mocked(isApiClientError).mockReturnValueOnce(true) + + useTableRow(WORKSPACE_ID, TABLE_ID, 'missing-row') + + const options = getQueryOptions() + expect(options.enabled).toBe(true) + await expect(options.queryFn({ signal: new AbortController().signal })).resolves.toBeNull() + }) + + it('forwards the row scope and cancellation signal through the shared contract', async () => { + const row = { id: 'row-1', data: { name: 'Acme' } } + const signal = new AbortController().signal + vi.mocked(requestJson).mockResolvedValueOnce({ data: { row } }) + + useTableRow(WORKSPACE_ID, TABLE_ID, row.id) + + await expect(getQueryOptions().queryFn({ signal })).resolves.toEqual(row) + expect(requestJson).toHaveBeenCalledWith(getTableRowContract, { + params: { tableId: TABLE_ID, rowId: row.id }, + query: { workspaceId: WORKSPACE_ID }, + signal, + }) + }) + + it('preserves non-404 API failures', async () => { + const error = { status: 500 } + vi.mocked(requestJson).mockRejectedValueOnce(error) + vi.mocked(isApiClientError).mockReturnValueOnce(true) + + useTableRow(WORKSPACE_ID, TABLE_ID, 'row-1') + + await expect(getQueryOptions().queryFn({ signal: new AbortController().signal })).rejects.toBe( + error + ) + }) + + it('preserves failures that are not API client errors', async () => { + const error = new Error('connection failed') + vi.mocked(requestJson).mockRejectedValueOnce(error) + + useTableRow(WORKSPACE_ID, TABLE_ID, 'row-1') + + await expect(getQueryOptions().queryFn({ signal: new AbortController().signal })).rejects.toBe( + error + ) + }) +}) + +describe('useReferenceRowPreview', () => { + function getQueryOptions() { + return vi.mocked(useQuery).mock.calls.at(-1)?.[0] as { + enabled: boolean + gcTime: number + queryKey: readonly unknown[] + placeholderData: unknown + refetchOnMount: 'always' + refetchOnReconnect: boolean + refetchOnWindowFocus: boolean + staleTime: number + queryFn: (context: { signal: AbortSignal }) => Promise + } + } + + it('isolates each opening and fetches only the referenced row', async () => { + const row = { id: 'row-1', data: { name: 'Acme' } } + const table = { id: TABLE_ID, name: 'Accounts', schema: { columns: [] } } + const signal = new AbortController().signal + queryClient.ensureQueryData.mockResolvedValueOnce(table) + vi.mocked(requestJson).mockResolvedValueOnce({ data: { row } }) + + useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, row.id, 'source-row-1', 'account') + + const options = getQueryOptions() + expect(options).toMatchObject({ + enabled: true, + gcTime: 0, + placeholderData: expect.anything(), + queryKey: tableKeys.referencePreview(TABLE_ID, row.id, 'source-row-1', 'account'), + refetchOnMount: 'always', + refetchOnReconnect: false, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + }) + await expect(options.queryFn({ signal })).resolves.toEqual({ + tableId: TABLE_ID, + rowId: row.id, + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + table, + row, + }) + expect(queryClient.ensureQueryData).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: tableKeys.detail(TABLE_ID), + staleTime: Number.POSITIVE_INFINITY, + }) + ) + expect(requestJson).toHaveBeenCalledOnce() + expect(requestJson).toHaveBeenCalledWith(getTableRowContract, { + params: { tableId: TABLE_ID, rowId: row.id }, + query: { workspaceId: WORKSPACE_ID }, + signal, + }) + }) + + it('does not fetch until every referenced-row identity is available', () => { + useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, undefined) + + expect(getQueryOptions().enabled).toBe(false) + }) + + it('uses the source cell to identify each preview opening', () => { + useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, 'row-1', 'source-row-1', 'account') + const firstOpening = getQueryOptions().queryKey + + useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, 'row-1', 'source-row-2', 'account') + + expect(getQueryOptions().queryKey).not.toEqual(firstOpening) + }) +}) + +describe('useReferenceTableMetadata', () => { + it('prefetches only distinct referenced tables through the shared detail cache', async () => { + useReferenceTableMetadata(WORKSPACE_ID, ['table-z', 'table-a', 'table-z']) + + const queries = vi.mocked(useQueries).mock.calls.at(-1)?.[0].queries as Array<{ + enabled: boolean + queryFn: (context: { signal: AbortSignal }) => Promise + queryKey: readonly unknown[] + refetchOnWindowFocus: boolean + staleTime: number + }> + expect(queries.map(({ queryKey }) => queryKey)).toEqual([ + tableKeys.detail('table-a'), + tableKeys.detail('table-z'), + ]) + expect(queries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + enabled: true, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + }), + ]) + ) + + const signal = new AbortController().signal + const table = { id: 'table-a', name: 'Accounts', schema: { columns: [] } } + vi.mocked(requestJson).mockResolvedValueOnce({ data: { table } }) + await expect(queries[0].queryFn({ signal })).resolves.toEqual(table) + expect(requestJson).toHaveBeenCalledWith(getTableContract, { + params: { tableId: 'table-a' }, + query: { workspaceId: WORKSPACE_ID }, + signal, + }) + }) + + it('keeps metadata prefetch disabled without a workspace', () => { + useReferenceTableMetadata(undefined, ['table-a']) + + const queries = vi.mocked(useQueries).mock.calls.at(-1)?.[0].queries as Array<{ + enabled: boolean + }> + expect(queries[0].enabled).toBe(false) + }) +}) + +describe('useBatchUpdateTableRows', () => { + it('invalidates cached row details and matching reference previews after a batch write settles', () => { + const hook = useBatchUpdateTableRows({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + const updates = [ + { rowId: 'row-1', data: { name: 'Acme' } }, + { rowId: 'row-2', data: { name: 'Globex' } }, + ] + + hook.onSettled?.(undefined, null, { updates }, undefined) + + expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(2) + const options = queryClient.invalidateQueries.mock.calls[0]?.[0] + expect(options?.queryKey).toEqual(tableKeys.rowsRoot(TABLE_ID)) + expect(options?.predicate({ queryKey: tableKeys.row(TABLE_ID, 'row-1') })).toBe(true) + expect(options?.predicate({ queryKey: tableKeys.row(TABLE_ID, 'row-2') })).toBe(true) + expect(options?.predicate({ queryKey: tableKeys.row(TABLE_ID, 'row-3') })).toBe(false) + expect(options?.predicate({ queryKey: tableKeys.infiniteRowsRoot(TABLE_ID) })).toBe(false) + + const previewOptions = queryClient.invalidateQueries.mock.calls[1]?.[0] + expect(previewOptions?.queryKey).toEqual(tableKeys.referencePreviews()) + expect( + previewOptions?.predicate({ + queryKey: tableKeys.referencePreview(TABLE_ID, 'row-1', 'source-row', 'account'), + }) + ).toBe(true) + expect( + previewOptions?.predicate({ + queryKey: tableKeys.referencePreview(TABLE_ID, 'row-3', 'source-row', 'account'), + }) + ).toBe(false) + expect( + previewOptions?.predicate({ + queryKey: tableKeys.referencePreview('other-table', 'row-1', 'source-row', 'account'), + }) + ).toBe(false) + }) +}) + +describe('reference preview invalidation', () => { + function expectPreviewInvalidation(rowIds: string[]) { + const call = queryClient.invalidateQueries.mock.calls.find( + ([options]) => + JSON.stringify(options?.queryKey) === JSON.stringify(tableKeys.referencePreviews()) + ) + expect(call).toBeDefined() + const options = call?.[0] + for (const rowId of rowIds) { + expect( + options?.predicate({ + queryKey: tableKeys.referencePreview(TABLE_ID, rowId, 'source-row', 'account'), + }) + ).toBe(true) + } + expect( + options?.predicate({ + queryKey: tableKeys.referencePreview(TABLE_ID, 'untouched-row', 'source-row', 'account'), + }) + ).toBe(false) + } + + it('invalidates a referenced row after an update settles', () => { + const hook = useUpdateTableRow({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + + hook.onSettled?.(undefined, null, { rowId: 'row-1', data: { name: 'Acme' } }, undefined) + + expectPreviewInvalidation(['row-1']) + }) + + it('invalidates a referenced row after a delete settles', () => { + const hook = useDeleteTableRow({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + + hook.onSettled?.(undefined, null, 'row-1', undefined) + + expectPreviewInvalidation(['row-1']) + }) + + it('invalidates every referenced row after a bulk delete settles', () => { + const hook = useDeleteTableRows({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + + hook.onSettled?.(undefined, null, ['row-1', 'row-2'], undefined) + + expectPreviewInvalidation(['row-1', 'row-2']) + }) +}) + describe('useUpdateTableView autosave ordering', () => { it('serializes config and layout patches for the same table', () => { const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 22b4359b9f8..4656399d287 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -12,6 +12,7 @@ import { keepPreviousData, useInfiniteQuery, useMutation, + useQueries, useQuery, useQueryClient, } from '@tanstack/react-query' @@ -59,8 +60,10 @@ import { deleteTableViewContract, deleteWorkflowGroupContract, findTableRowsContract, + type GetTableRowResponse, getEnrichmentDetailContract, getTableContract, + getTableRowContract, type InsertTableRowBodyInput, listActiveDispatchesContract, listTableJobsContract, @@ -144,6 +147,9 @@ export const TABLE_FIND_STALE_TIME = 30 * 1000 export const TABLE_FIND_GC_TIME = 60 * 1000 export const TABLE_ROWS_STALE_TIME = 30 * 1000 export const TABLE_EXPORT_JOBS_STALE_TIME = 5 * 1000 +export const TABLE_REFERENCE_PREVIEW_STALE_TIME = Number.POSITIVE_INFINITY +export const TABLE_REFERENCE_PREVIEW_GC_TIME = 0 +export const TABLE_REFERENCE_METADATA_STALE_TIME = Number.POSITIVE_INFINITY type TableRowsParams = Omit & TableIdParamsInput & { @@ -217,12 +223,47 @@ async function fetchTableRows({ return { rows, totalCount, nextCursor } } +async function fetchTableRow( + workspaceId: string, + tableId: string, + rowId: string, + signal?: AbortSignal +): Promise { + try { + const response = await requestJson(getTableRowContract, { + params: { tableId, rowId }, + query: { workspaceId }, + signal, + }) + return response.data.row + } catch (error) { + if (isApiClientError(error) && error.status === 404) return null + throw error + } +} + function invalidateRowCount(queryClient: ReturnType, tableId: string) { queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) } +function invalidateReferencePreviews( + queryClient: ReturnType, + tableId: string, + rowIds: ReadonlySet +) { + const previewsRoot = tableKeys.referencePreviews() + queryClient.invalidateQueries({ + queryKey: previewsRoot, + predicate: (query) => { + const targetTableId = query.queryKey[previewsRoot.length] + const targetRowId = query.queryKey[previewsRoot.length + 1] + return targetTableId === tableId && typeof targetRowId === 'string' && rowIds.has(targetRowId) + }, + }) +} + /** * Invalidate only the row-count surfaces — the table detail and the tables * list, both of which carry the unfiltered `rowCount`. Deliberately leaves @@ -314,6 +355,69 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde }) } +/** Reads one row on demand. A missing row resolves to null for reference previews. */ +export function useTableRow( + workspaceId: string | undefined, + tableId: string | undefined, + rowId: string | undefined +) { + // rq-lint-allow: tableId and rowId are globally unique; workspaceId is only an authz scope on the fetch and cannot collide across workspaces + return useQuery({ + queryKey: tableKeys.row(tableId ?? '', rowId ?? ''), + queryFn: ({ signal }) => + fetchTableRow(workspaceId as string, tableId as string, rowId as string, signal), + enabled: Boolean(workspaceId && tableId && rowId), + staleTime: TABLE_ROWS_STALE_TIME, + }) +} + +/** + * Fetches an isolated table-and-row snapshot for one reference preview opening. + * + * Referenced table metadata normally comes from detail queries loaded with the grid, so ensuring it + * here reuses the cache while making the schema and row one atomic result. Previous complete data + * remains visible when the query key changes, allowing the grid to switch previews only after the + * next target settles. The preview key remains outside ordinary row roots so active-table mutations + * cannot replace the open snapshot. + */ +export function useReferenceRowPreview( + workspaceId: string | undefined, + tableId: string | undefined, + rowId: string | undefined, + sourceRowId?: string, + sourceColumnKey?: string +) { + const queryClient = useQueryClient() + // rq-lint-allow: tableId is globally unique; workspaceId is only an authz scope on the fetch and cannot collide across workspaces + return useQuery({ + queryKey: tableKeys.referencePreview(tableId ?? '', rowId ?? '', sourceRowId, sourceColumnKey), + queryFn: async ({ signal }) => { + const [table, row] = await Promise.all([ + queryClient.ensureQueryData({ + ...getTableDetailQueryOptions(workspaceId as string, tableId as string), + staleTime: TABLE_REFERENCE_METADATA_STALE_TIME, + }), + fetchTableRow(workspaceId as string, tableId as string, rowId as string, signal), + ]) + return { + tableId: tableId as string, + rowId: rowId as string, + sourceRowId: sourceRowId as string, + sourceColumnKey: sourceColumnKey as string, + table, + row, + } + }, + enabled: Boolean(workspaceId && tableId && rowId && sourceRowId && sourceColumnKey), + placeholderData: keepPreviousData, + staleTime: TABLE_REFERENCE_PREVIEW_STALE_TIME, + gcTime: TABLE_REFERENCE_PREVIEW_GC_TIME, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) +} + /** * Shared table-detail query options so non-component callers (e.g. selector * providers) can `ensureQueryData` the same cache entry `useTable` populates. @@ -326,6 +430,27 @@ export function getTableDetailQueryOptions(workspaceId: string, tableId: string) } } +/** + * Prefetches each referenced table's name and schema when its source grid loads. + * The detail keys are shared with {@link useTable}, so cached definitions are reused and + * schema invalidations still refresh active observers. + */ +export function useReferenceTableMetadata( + workspaceId: string | undefined, + tableIds: ReadonlyArray +) { + const uniqueTableIds = Array.from(new Set(tableIds)).sort() + // rq-lint-allow: table IDs are globally unique; workspaceId is only an authz scope on each detail fetch + return useQueries({ + queries: uniqueTableIds.map((tableId) => ({ + ...getTableDetailQueryOptions(workspaceId ?? '', tableId), + enabled: Boolean(workspaceId), + staleTime: TABLE_REFERENCE_METADATA_STALE_TIME, + refetchOnWindowFocus: false, + })), + }) +} + export interface TableRunState { dispatches: ActiveDispatch[] runningByRowId: Record @@ -1154,6 +1279,9 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, + onSettled: (_data, _error, { rowId }) => { + invalidateReferencePreviews(queryClient, tableId, new Set([rowId])) + }, }) } @@ -1228,6 +1356,22 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, + onSettled: (_data, _error, { updates }) => { + const rowIds = new Set(updates.map(({ rowId }) => rowId)) + const rowsRoot = tableKeys.rowsRoot(tableId) + queryClient.invalidateQueries({ + queryKey: rowsRoot, + predicate: (query) => { + const rowId = query.queryKey[rowsRoot.length + 1] + return ( + query.queryKey[rowsRoot.length] === 'row' && + typeof rowId === 'string' && + rowIds.has(rowId) + ) + }, + }) + invalidateReferencePreviews(queryClient, tableId, rowIds) + }, }) } @@ -1249,8 +1393,9 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext) if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, - onSettled: () => { + onSettled: (_data, _error, rowId) => { invalidateRowCount(queryClient, tableId) + invalidateReferencePreviews(queryClient, tableId, new Set([rowId])) }, }) } @@ -1298,8 +1443,9 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, - onSettled: () => { + onSettled: (_data, _error, rowIds) => { invalidateRowCount(queryClient, tableId) + invalidateReferencePreviews(queryClient, tableId, new Set(rowIds)) }, }) } diff --git a/apps/sim/hooks/queries/utils/table-keys.ts b/apps/sim/hooks/queries/utils/table-keys.ts index 5ccf7f34457..32567455032 100644 --- a/apps/sim/hooks/queries/utils/table-keys.ts +++ b/apps/sim/hooks/queries/utils/table-keys.ts @@ -25,6 +25,10 @@ export const tableKeys = { exportJobs: (workspaceId?: string) => [...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const, rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const, + row: (tableId: string, rowId: string) => [...tableKeys.rowsRoot(tableId), 'row', rowId] as const, + referencePreviews: () => [...tableKeys.all, 'reference-preview'] as const, + referencePreview: (tableId: string, rowId: string, sourceRowId = '', sourceColumnKey = '') => + [...tableKeys.referencePreviews(), tableId, rowId, sourceRowId, sourceColumnKey] as const, /** * Prefix covering only the paged row lists. `rowsRoot` is a shared parent — `find` * hangs off it holding a different shape — so anything walking the cache for row diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index b732484a973..6c5e075d9c2 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1464,6 +1464,8 @@ export const getTableRowContract = defineRouteContract({ }, }) +export type GetTableRowResponse = ContractJsonResponse + export const updateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/table/[tableId]/rows/[rowId]', 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/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 0fa9d09d64a..d1530ffda77 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -4213,7 +4213,7 @@ export const QueryUserTable: ToolCatalogEntry = { filter: { type: 'object', description: - 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', @@ -5473,7 +5473,53 @@ export const TableColumns: ToolCatalogEntry = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }; type may be string, number, boolean, date, json, select, or ttl. Select (enum) columns also take { options: [names], multiple?: true } — options is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: [names], multiple?: true }; reference requires referenceTableId.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + name: { type: 'string' }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { type: 'string' }, + }, + position: { type: 'integer' }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -5485,6 +5531,11 @@ export const TableColumns: ToolCatalogEntry = { description: 'Array of column names to delete at once (preferred for multi-column delete_column)', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, multiple: { type: 'boolean', description: @@ -5494,7 +5545,17 @@ export const TableColumns: ToolCatalogEntry = { newType: { type: 'string', description: - 'New column type for update_column: string, number, boolean, date, json, select, ttl. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type for update_column: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], }, options: { type: 'array', @@ -5507,6 +5568,11 @@ export const TableColumns: ToolCatalogEntry = { description: 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, tableId: { type: 'string', description: 'Table ID (required for every operation)' }, unique: { type: 'boolean', @@ -5673,7 +5739,7 @@ export const TableManage: ToolCatalogEntry = { schema: { type: 'object', description: - 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, boolean, date, json, select, and ttl. A select (enum) column also requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; currency takes currencyCode?, reference requires referenceTableId, and select requires options (display names) and takes multiple?.', }, tableId: { type: 'string', @@ -5719,12 +5785,12 @@ export const TableRows: ToolCatalogEntry = { data: { type: 'object', description: - 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME. TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds. On insert_row, a missing or null TTL means no expiration. On update_row and update_rows_by_filter, omit the TTL to preserve its current value or set it to null to clear the expiration.', + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', }, filter: { type: 'object', description: - 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES. TTL filter values are absolute whole Unix epoch seconds.', + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', }, limit: { type: 'number', @@ -5749,19 +5815,18 @@ export const TableRows: ToolCatalogEntry = { }, rows: { type: 'array', - description: - 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + description: 'Array of row data objects (required for batch_insert_rows)', }, tableId: { type: 'string', description: 'Table ID (required for every operation)' }, updates: { type: 'array', description: - "Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', }, values: { type: 'object', description: - "Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName). For a TTL column, values are absolute whole Unix epoch seconds, never JavaScript milliseconds; set a row's value to null to clear its expiration, and omit the row from the map to leave it unchanged.", + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', }, }, required: ['tableId'], @@ -6079,7 +6144,52 @@ export const UserTable: ToolCatalogEntry = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, boolean, date, json, select, or ttl. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', + }, + name: { type: 'string' }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', + items: { type: 'string' }, + }, + position: { type: 'integer' }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: 'Set column unique constraint (optional for update_column)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -6091,6 +6201,11 @@ export const UserTable: ToolCatalogEntry = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, cursor: { type: 'string', description: @@ -6098,8 +6213,7 @@ export const UserTable: ToolCatalogEntry = { }, data: { type: 'object', - description: - 'Row data as key-value pairs (required for insert_row, update_row). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds. On insert_row, a missing or null TTL means no expiration. On update_row, omit the TTL to preserve its current value or set it to null to clear the expiration.', + description: 'Row data as key-value pairs (required for insert_row, update_row)', }, dependencies: { type: 'object', @@ -6128,7 +6242,7 @@ export const UserTable: ToolCatalogEntry = { filter: { type: 'object', description: - 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -6217,7 +6331,17 @@ export const UserTable: ToolCatalogEntry = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json, select, ttl. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type (optional for update_column). Types: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], }, options: { type: 'array', @@ -6282,6 +6406,11 @@ export const UserTable: ToolCatalogEntry = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, rowId: { type: 'string', description: @@ -6295,8 +6424,7 @@ export const UserTable: ToolCatalogEntry = { }, rows: { type: 'array', - description: - 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + description: 'Array of row data objects (required for batch_insert_rows)', }, runMode: { type: 'string', @@ -6307,7 +6435,7 @@ export const UserTable: ToolCatalogEntry = { schema: { type: 'object', description: - 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, boolean, date, json, select, and ttl. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types: string, number, currency, boolean, date, json, select, reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.', }, scope: { type: 'string', @@ -6332,12 +6460,12 @@ export const UserTable: ToolCatalogEntry = { updates: { type: 'array', description: - "Array of per-row updates: [{ rowId, data: { col: val } }] (for batch_update_rows). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + 'Array of per-row updates: [{ rowId, data: { col: val } }] (for batch_update_rows)', }, values: { type: 'object', description: - 'Map of rowId to value for single-column batch update: { "rowId1": val1, "rowId2": val2 } (for batch_update_rows with columnName). For a TTL column, values are absolute whole Unix epoch seconds, never JavaScript milliseconds; set a row\'s value to null to clear its expiration, and omit the row from the map to leave it unchanged.', + 'Map of rowId to value for single-column batch update: { "rowId1": val1, "rowId2": val2 } (for batch_update_rows with columnName)', }, workflowId: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 25cd935add7..552a3a29e32 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4094,7 +4094,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', @@ -5348,7 +5348,59 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }; type may be string, number, boolean, date, json, select, or ttl. Select (enum) columns also take { options: [names], multiple?: true } — options is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: [names], multiple?: true }; reference requires referenceTableId.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.', + }, + name: { + type: 'string', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.', + items: { + type: 'string', + }, + }, + position: { + type: 'integer', + }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: + 'Set or clear the column unique constraint (update_column; not supported on select columns)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -5360,6 +5412,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (preferred for multi-column delete_column)', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, multiple: { type: 'boolean', description: @@ -5372,7 +5429,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - 'New column type for update_column: string, number, boolean, date, json, select, ttl. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type for update_column: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], }, options: { type: 'array', @@ -5387,6 +5454,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, tableId: { type: 'string', description: 'Table ID (required for every operation)', @@ -5578,7 +5650,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { schema: { type: 'object', description: - 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; types are string, number, boolean, date, json, select, and ttl. A select (enum) column also requires options (display names) and takes multiple?. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; currency takes currencyCode?, reference requires referenceTableId, and select requires options (display names) and takes multiple?.', }, tableId: { type: 'string', @@ -5628,12 +5700,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { data: { type: 'object', description: - 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME. TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds. On insert_row, a missing or null TTL means no expiration. On update_row and update_rows_by_filter, omit the TTL to preserve its current value or set it to null to clear the expiration.', + 'Row data as column → value pairs (required for insert_row, update_row; the patch object for update_rows_by_filter). Select (enum) cells take the option NAME.', }, filter: { type: 'object', description: - 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES. TTL filter values are absolute whole Unix epoch seconds.', + 'Predicate filter for update_rows_by_filter / delete_rows_by_filter: {"all":[...]} (AND) or {"any":[...]} (OR) of {field, op, value} leaves or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (* wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array. Single-select columns match by eq/ne/in/nin; multiple-select by contains/ncontains — values are option NAMES.', }, limit: { type: 'number', @@ -5663,8 +5735,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, rows: { type: 'array', - description: - 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + description: 'Array of row data objects (required for batch_insert_rows)', }, tableId: { type: 'string', @@ -5673,12 +5744,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { updates: { type: 'array', description: - "Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + 'Array of per-row updates: [{ rowId, data: { col: val } }] (batch_update_rows format a)', }, values: { type: 'object', description: - "Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName). For a TTL column, values are absolute whole Unix epoch seconds, never JavaScript milliseconds; set a row's value to null to clear its expiration, and omit the row from the map to leave it unchanged.", + 'Map of rowId → value for single-column batch update (batch_update_rows format b, with columnName)', }, }, required: ['tableId'], @@ -6008,7 +6079,58 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { column: { type: 'object', description: - 'Column definition for add_column: { name, type, unique?, position? }. Type may be string, number, boolean, date, json, select, or ttl. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.', + properties: { + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, + multiple: { + type: 'boolean', + description: + 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', + }, + name: { + type: 'string', + }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', + items: { + type: 'string', + }, + }, + position: { + type: 'integer', + }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, + type: { + type: 'string', + description: + 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], + }, + unique: { + type: 'boolean', + description: 'Set column unique constraint (optional for update_column)', + }, + }, + required: ['name', 'type'], }, columnName: { type: 'string', @@ -6020,6 +6142,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + currencyCode: { + type: 'string', + description: + 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.', + }, cursor: { type: 'string', description: @@ -6027,8 +6154,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, data: { type: 'object', - description: - 'Row data as key-value pairs (required for insert_row, update_row). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds. On insert_row, a missing or null TTL means no expiration. On update_row, omit the TTL to preserve its current value or set it to null to clear the expiration.', + description: 'Row data as key-value pairs (required for insert_row, update_row)', }, dependencies: { type: 'object', @@ -6062,7 +6188,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. TTL filter values are absolute whole Unix epoch seconds, never milliseconds. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -6159,7 +6285,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json, select, ttl. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips. Converting to ttl enables row expiration and fails if the table already has another ttl column; TTL values are absolute whole Unix epoch seconds, not milliseconds.', + 'New column type (optional for update_column). Types: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', + enum: [ + 'string', + 'number', + 'currency', + 'boolean', + 'date', + 'json', + 'select', + 'reference', + ], }, options: { type: 'array', @@ -6232,6 +6368,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, + referenceTableId: { + type: 'string', + description: + 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.', + }, rowId: { type: 'string', description: @@ -6247,8 +6388,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, rows: { type: 'array', - description: - 'Array of row data objects (required for batch_insert_rows). TTL cells take absolute whole Unix epoch seconds, never JavaScript milliseconds; a missing or null TTL means no expiration.', + description: 'Array of row data objects (required for batch_insert_rows)', }, runMode: { type: 'string', @@ -6259,7 +6399,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { schema: { type: 'object', description: - 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types are string, number, boolean, date, json, select, and ttl. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select. A table may have at most one ttl column; adding it enables row expiration, with cell values stored as absolute whole Unix epoch seconds rather than milliseconds.', + 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types: string, number, currency, boolean, date, json, select, reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.', }, scope: { type: 'string', @@ -6286,12 +6426,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { updates: { type: 'array', description: - "Array of per-row updates: [{ rowId, data: { col: val } }] (for batch_update_rows). TTL values are absolute whole Unix epoch seconds, never JavaScript milliseconds; omit a row's TTL key to preserve it or set it to null to clear the expiration.", + 'Array of per-row updates: [{ rowId, data: { col: val } }] (for batch_update_rows)', }, values: { type: 'object', description: - 'Map of rowId to value for single-column batch update: { "rowId1": val1, "rowId2": val2 } (for batch_update_rows with columnName). For a TTL column, values are absolute whole Unix epoch seconds, never JavaScript milliseconds; set a row\'s value to null to clear its expiration, and omit the row from the map to leave it unchanged.', + 'Map of rowId to value for single-column batch update: { "rowId1": val1, "rowId2": val2 } (for batch_update_rows with columnName)', }, workflowId: { type: 'string', 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..a4fdf680b8b 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -68,6 +68,14 @@ 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, navigation, metadata prefetch, and row-preview 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/folders/bulk.test.ts b/apps/sim/lib/folders/bulk.test.ts index 08bb9092973..4fc6c927e7d 100644 --- a/apps/sim/lib/folders/bulk.test.ts +++ b/apps/sim/lib/folders/bulk.test.ts @@ -46,6 +46,7 @@ describe('planFolderSelection', () => { expect(result.selected).toEqual([{ id: 'a', name: 'A' }]) expect(result.contained).toEqual([]) expect([...result.covered].sort()).toEqual(['a', 'a1', 'a1x']) + expect([...(result.coveredBySelected.get('a') ?? [])].sort()).toEqual(['a', 'a1', 'a1x']) }) it('reports an explicitly selected descendant as contained, not as a second selection', async () => { diff --git a/apps/sim/lib/folders/bulk.ts b/apps/sim/lib/folders/bulk.ts index 2e32c343132..3e09829cb5f 100644 --- a/apps/sim/lib/folders/bulk.ts +++ b/apps/sim/lib/folders/bulk.ts @@ -35,6 +35,8 @@ export interface FolderSelectionPlan { * acted on a second time. */ covered: Set + /** The covered subtree for each top-level selected folder, used for per-folder preflight. */ + coveredBySelected: Map> } /** @@ -52,7 +54,13 @@ export async function planFolderSelection( folderIds: readonly string[] ): Promise { if (folderIds.length === 0) { - return { selected: [], notFound: [], contained: [], covered: new Set() } + return { + selected: [], + notFound: [], + contained: [], + covered: new Set(), + coveredBySelected: new Map(), + } } const rows = await listActiveFolderRows(workspaceId, resourceType, { @@ -64,6 +72,7 @@ export async function planFolderSelection( const notFound: string[] = [] const contained: BulkFolderAffected[] = [] const covered = new Set() + const coveredBySelected = new Map>() const requested = new Set() for (const folderId of folderIds) { @@ -111,8 +120,9 @@ export async function planFolderSelection( } if (covered.has(folderId)) continue selected.push(entry) - covered.add(folderId) - for (const descendantId of descendantsOf.get(folderId) ?? []) covered.add(descendantId) + const selectedCoverage = new Set([folderId, ...(descendantsOf.get(folderId) ?? [])]) + coveredBySelected.set(folderId, selectedCoverage) + for (const coveredId of selectedCoverage) covered.add(coveredId) } /** @@ -126,7 +136,7 @@ export async function planFolderSelection( for (const descendantId of descendantsOf.get(folder.id) ?? []) covered.add(descendantId) } - return { selected, notFound, contained, covered } + return { selected, notFound, contained, covered, coveredBySelected } } /** diff --git a/apps/sim/lib/folders/cascade.test.ts b/apps/sim/lib/folders/cascade.test.ts index 5c4af123168..7fd73951b7a 100644 --- a/apps/sim/lib/folders/cascade.test.ts +++ b/apps/sim/lib/folders/cascade.test.ts @@ -1,7 +1,13 @@ /** * @vitest-environment node */ -import { flattenMockConditions, hasMockCondition } from '@sim/testing' +import { + flattenMockConditions, + hasMockCondition, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { archiveFolderCascade, @@ -540,3 +546,48 @@ describe('knowledge_base and table folder resources', () => { expect(tableConfig.sortOrderColumn).toBeUndefined() }) }) + +describe('table folder deletion guard', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('refuses the whole folder before a referenced table can be archived', async () => { + queueTableRows(schemaMock.userTableDefinitions, []) + queueTableRows(schemaMock.userTableDefinitions, [ + { + id: 'tbl_customers', + name: 'Customers', + folderId: 'folder-child', + schema: { columns: [{ id: 'name', name: 'Name', type: 'string' }] }, + }, + { + id: 'tbl_orders', + name: 'Orders', + folderId: null, + schema: { + columns: [ + { + id: 'customer', + name: 'Customer', + type: 'reference', + referenceTableId: 'tbl_customers', + }, + ], + }, + }, + ]) + + await expect( + FOLDER_RESOURCES.table.guardDelete?.({ + workspaceId: 'ws-1', + folderIds: ['folder-root', 'folder-child'], + }) + ).resolves.toEqual({ + error: + 'Cannot delete table "Customers" because it is referenced by table "Orders". Remove the reference column first.', + errorCode: 'conflict', + }) + }) +}) diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts index cca02a55313..1ed4a4ff349 100644 --- a/apps/sim/lib/folders/config.ts +++ b/apps/sim/lib/folders/config.ts @@ -305,8 +305,9 @@ async function restoreKnowledgeBaseChildren(context: CascadeChildrenContext): Pr /** * Archives the tables in a folder subtree through the canonical table delete, so the - * `deleteLocked` guard in its WHERE clause still applies. {@link guardLockedTables} has - * already refused the whole folder if any table is locked, so this should not encounter one. + * `deleteLocked` and inbound-reference guards still apply. {@link guardTableDeletion} has + * already refused the whole folder if any table cannot be deleted, so this should not + * encounter a partial cascade. */ async function archiveTableChildren(context: CascadeChildrenContext): Promise { const { deleteTable } = await import('@/lib/table/service') @@ -332,11 +333,13 @@ async function restoreTableChildren(context: CascadeChildrenContext): Promise { - const [{ db }, { and, eq: eqOp, inArray, isNull }] = await Promise.all([ + const [ + { db }, + { and, eq: eqOp, inArray, isNull }, + { findActiveTableReferenceBlockers, tableReferenceBlockerMessage }, + ] = await Promise.all([ import('@sim/db'), import('drizzle-orm'), + import('@/lib/table/column-types/registry.server'), ]) const locked = await db @@ -376,12 +383,22 @@ async function guardLockedTables({ ) ) - if (locked.length === 0) return null + if (locked.length > 0) { + const names = locked.map((row) => row.name).join(', ') + return { + error: `Cannot delete folder: ${locked.length === 1 ? 'table' : 'tables'} ${names} ${locked.length === 1 ? 'is' : 'are'} delete-locked`, + errorCode: 'locked', + } + } + + const [blocker] = await findActiveTableReferenceBlockers(db, workspaceId, { + folderIds: new Set(folderIds), + }) + if (!blocker) return null - const names = locked.map((row) => row.name).join(', ') return { - error: `Cannot delete folder: ${locked.length === 1 ? 'table' : 'tables'} ${names} ${locked.length === 1 ? 'is' : 'are'} delete-locked`, - errorCode: 'locked', + error: tableReferenceBlockerMessage(blocker.targetTableName, [blocker.referencingTableName]), + errorCode: 'conflict', } } @@ -515,7 +532,7 @@ export const FOLDER_RESOURCES: Record >, archiveChildren: archiveTableChildren, restoreChildren: restoreTableChildren, - guardDelete: guardLockedTables, + guardDelete: guardTableDeletion, }, } diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts index 0726111a1ba..6e03f43fc87 100644 --- a/apps/sim/lib/table/application/bulk.test.ts +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ resolveWorkspaceContext: vi.fn(), signal: vi.fn(), notifyTables: vi.fn(), + findReferenceBlockers: vi.fn(), resolveFolderPathFromIndex: vi.fn(), resolveTableFolderPath: vi.fn(), })) @@ -76,6 +77,11 @@ vi.mock('@/lib/table/application/context', () => ({ resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, })) vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) +vi.mock('@/lib/table/column-types/registry.server', () => ({ + findActiveTableReferenceBlockers: mocks.findReferenceBlockers, + tableReferenceBlockerMessage: (target: string, blockers: string[]) => + `Cannot delete table "${target}" because it is referenced by table "${blockers[0]}". Remove the reference column first.`, +})) import { OrchestrationError } from '@/lib/core/orchestration/types' import { bulkDeleteTables, bulkMoveTables } from '@/lib/table/application/bulk' @@ -118,6 +124,7 @@ describe('table bulk application use cases', () => { mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('write') mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findReferenceBlockers.mockResolvedValue([]) mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) mocks.resolveTableContext.mockImplementation(async (tableId: string) => tableContext(tableId)) mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' }) @@ -262,6 +269,82 @@ describe('table bulk application use cases', () => { expect(result.deleted).toEqual([{ kind: 'table', id: 'table-2', name: 'Archived' }]) }) + it('does not delete a referenced target even when its referring table is selected first', async () => { + mocks.findReferenceBlockers.mockResolvedValueOnce([ + { + targetTableId: 'customers', + targetTableName: 'Customers', + targetFolderId: null, + referencingTableId: 'orders', + referencingTableName: 'Orders', + }, + ]) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['orders', 'customers'], + folderKeying: 'ids' as const, + folders: [], + }, + }) + + expect(result.deleted).toEqual([{ kind: 'table', id: 'orders', name: 'Archived' }]) + expect(result.failed).toEqual([ + { + kind: 'table', + id: 'customers', + name: 'Table customers', + reason: + 'Cannot delete table "Table customers" because it is referenced by table "Orders". Remove the reference column first.', + }, + ]) + expect(mocks.deleteTable).toHaveBeenCalledTimes(1) + expect(mocks.deleteTable).toHaveBeenCalledWith('orders', 'request-1', expect.anything()) + }) + + it('blocks a selected folder when it contains a referenced table', async () => { + mocks.planFolderSelection.mockResolvedValueOnce({ + selected: [{ id: 'folder-1', name: 'Sales' }], + notFound: [], + contained: [], + covered: new Set(['folder-1', 'folder-child']), + coveredBySelected: new Map([['folder-1', new Set(['folder-1', 'folder-child'])]]), + }) + mocks.findReferenceBlockers.mockResolvedValueOnce([ + { + targetTableId: 'customers', + targetTableName: 'Customers', + targetFolderId: 'folder-child', + referencingTableId: 'orders', + referencingTableName: 'Orders', + }, + ]) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: [], + folderKeying: 'ids' as const, + folders: ['folder-1'], + }, + }) + + expect(result.deleted).toEqual([]) + expect(result.failed).toEqual([ + { + kind: 'folder', + id: 'folder-1', + name: 'Sales', + reason: + 'Cannot delete table "Customers" because it is referenced by table "Orders". Remove the reference column first.', + }, + ]) + expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() + }) + it('conceals an inaccessible table as not-found rather than naming it', async () => { mocks.resolveTableContext.mockRejectedValueOnce( new OrchestrationError('not_found', 'Table not found') @@ -520,6 +603,7 @@ describe('path-keyed bulk table selections', () => { mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('write') mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findReferenceBlockers.mockResolvedValue([]) mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) mocks.resolveTableContext.mockImplementation(async (tableId: string) => tableContext(tableId)) mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' }) diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts index 9fd587b4732..dbb68495e63 100644 --- a/apps/sim/lib/table/application/bulk.ts +++ b/apps/sim/lib/table/application/bulk.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -32,6 +33,10 @@ import { } from '@/lib/table/application/context' import { resolveTableFolderPath } from '@/lib/table/application/folder-paths' import { tableOperations } from '@/lib/table/application/operations' +import { + findActiveTableReferenceBlockers, + tableReferenceBlockerMessage, +} from '@/lib/table/column-types/registry.server' import { signalTableSchemaChanged } from '@/lib/table/events' import { TableLockedError } from '@/lib/table/mutation-locks' @@ -496,6 +501,26 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({ TABLE_FOLDER_RESOURCE_TYPE, context.folderIds ) + const referenceBlockers = await findActiveTableReferenceBlockers(db, context.workspaceId, { + tableIds: context.tableIds, + folderIds: plan.covered, + }) + const blockersByTargetTableId = new Map() + for (const blocker of referenceBlockers) { + const targetBlockers = blockersByTargetTableId.get(blocker.targetTableId) ?? [] + targetBlockers.push(blocker) + blockersByTargetTableId.set(blocker.targetTableId, targetBlockers) + } + const blockedFolderIds = new Map( + plan.selected.flatMap((folder) => { + const coveredFolderIds = plan.coveredBySelected?.get(folder.id) ?? new Set([folder.id]) + const blocker = referenceBlockers.find( + (candidate) => + candidate.targetFolderId !== null && coveredFolderIds.has(candidate.targetFolderId) + ) + return blocker ? [[folder.id, blocker] as const] : [] + }) + ) const deleted: BulkTableItem[] = [] const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } @@ -508,6 +533,16 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({ plan.covered, (canonical) => authorizeTableOperation(principal, tableOperations.bulkDelete, canonical), async (canonical) => { + const blockers = blockersByTargetTableId.get(canonical.table.id) + if (blockers && blockers.length > 0) { + throw new OrchestrationError( + 'conflict', + tableReferenceBlockerMessage( + canonical.table.name, + blockers.map((blocker) => blocker.referencingTableName) + ) + ) + } const { archived } = await deleteTable(canonical.table.id, generateRequestId(), { expectedWorkspaceId: context.workspaceId, skipNotify: true, @@ -521,19 +556,33 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({ const deletedItems = { tables: deleted.length, folders: 0 } if (terminalError === undefined && plan.selected.length > 0) { - const folders = await bulkDeleteFolders({ - workspaceId: context.workspaceId, - resourceType: TABLE_FOLDER_RESOURCE_TYPE, - userId: resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }).attributedUserId, - folders: plan.selected, - countKey: 'tables', + const deletableFolders = plan.selected.filter((folder) => { + const blocker = blockedFolderIds.get(folder.id) + if (!blocker) return true + outcome.failed.push({ + kind: 'folder', + ...folder, + reason: tableReferenceBlockerMessage(blocker.targetTableName, [ + blocker.referencingTableName, + ]), + }) + return false }) - for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) - for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) - deletedItems.folders = folders.folderCount - deletedItems.tables += folders.resourceCount + if (deletableFolders.length > 0) { + const folders = await bulkDeleteFolders({ + workspaceId: context.workspaceId, + resourceType: TABLE_FOLDER_RESOURCE_TYPE, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + folders: deletableFolders, + countKey: 'tables', + }) + for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + deletedItems.folders = folders.folderCount + deletedItems.tables += folders.resourceCount + } } logger.info('Bulk archived tables and folders', { diff --git a/apps/sim/lib/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts index 7138c7fe866..932efbd0daf 100644 --- a/apps/sim/lib/table/column-types/reference.ts +++ b/apps/sim/lib/table/column-types/reference.ts @@ -15,6 +15,14 @@ export const referenceColumnType: ColumnTypeDefinition = { workflowInputType: 'string', editor: 'text', expandable: false, + referencePreview: { + getTableId(column) { + return column.referenceTableId + }, + getRowId(value) { + return typeof value === 'string' && value.length > 0 ? value : null + }, + }, coerce: stringColumnType.coerce, diff --git a/apps/sim/lib/table/column-types/registry.server.test.ts b/apps/sim/lib/table/column-types/registry.server.test.ts index 14f64905dd9..3dee497c3e5 100644 --- a/apps/sim/lib/table/column-types/registry.server.test.ts +++ b/apps/sim/lib/table/column-types/registry.server.test.ts @@ -4,17 +4,24 @@ import { hasMockCondition, schemaMock } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' -import { assertColumnReferencesInWorkspace } from '@/lib/table/column-types/registry.server' +import type { DbOrTx } from '@/lib/db/types' +import { + assertColumnReferencesInWorkspace, + findActiveTableReferenceBlockers, + tableReferenceBlockerMessage, +} 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 lock = vi.fn().mockResolvedValue(targetIds.map((id) => ({ id }))) + const where = vi.fn(() => ({ for: lock })) const from = vi.fn(() => ({ where })) const select = vi.fn(() => ({ from })) return { trx: { select } as unknown as DbTransaction, select, where, + lock, } } @@ -30,7 +37,7 @@ describe('assertColumnReferencesInWorkspace', () => { }) it('accepts active Reference targets returned for the workspace', async () => { - const { trx, select, where } = transactionWithTargets(['tbl_accounts', 'tbl_companies']) + const { trx, select, where, lock } = transactionWithTargets(['tbl_accounts', 'tbl_companies']) await assertColumnReferencesInWorkspace(trx, 'ws_1', [ { @@ -68,6 +75,7 @@ describe('assertColumnReferencesInWorkspace', () => { node.values.length === 2 ) ).toBe(true) + expect(lock).toHaveBeenCalledWith('key share') expect( hasMockCondition( condition, @@ -77,6 +85,53 @@ describe('assertColumnReferencesInWorkspace', () => { ).toBe(true) }) + it('admits archived targets that are part of the same restore cohort', async () => { + const { trx, 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', + }, + ], + { allowedArchivedTableIds: new Set(['tbl_companies']) } + ) + + const condition = where.mock.calls[0][0] + expect( + hasMockCondition( + condition, + (node) => + node.type === 'or' && + Array.isArray(node.conditions) && + node.conditions.some( + (nested) => + typeof nested === 'object' && + nested !== null && + 'type' in nested && + nested.type === 'inArray' && + 'column' in nested && + nested.column === schemaMock.userTableDefinitions.id && + 'values' in nested && + Array.isArray(nested.values) && + nested.values.length === 1 && + nested.values[0] === 'tbl_companies' + ) + ) + ).toBe(true) + }) + it('conceals missing, archived, and cross-workspace targets as not found', async () => { const { trx } = transactionWithTargets(['tbl_accounts']) @@ -101,3 +156,68 @@ describe('assertColumnReferencesInWorkspace', () => { }) }) }) + +describe('findActiveTableReferenceBlockers', () => { + const activeTables = [ + { + id: 'tbl_customers', + name: 'Customers', + folderId: 'folder_sales', + schema: { columns: [{ id: 'name', name: 'Name', type: 'string' }] }, + }, + { + id: 'tbl_orders', + name: 'Orders', + folderId: null, + schema: { + columns: [ + { + id: 'customer', + name: 'Customer', + type: 'reference', + referenceTableId: 'tbl_customers', + }, + ], + }, + }, + ] + + function executorWithTables() { + const where = vi.fn().mockResolvedValue(activeTables) + const from = vi.fn(() => ({ where })) + return { select: vi.fn(() => ({ from })) } as unknown as DbOrTx + } + + it('names the referring table for a selected target table', async () => { + await expect( + findActiveTableReferenceBlockers(executorWithTables(), 'ws_1', { + tableIds: ['tbl_customers'], + }) + ).resolves.toEqual([ + { + targetTableId: 'tbl_customers', + targetTableName: 'Customers', + targetFolderId: 'folder_sales', + referencingTableId: 'tbl_orders', + referencingTableName: 'Orders', + }, + ]) + }) + + it('finds referenced targets anywhere in a selected folder subtree', async () => { + const blockers = await findActiveTableReferenceBlockers(executorWithTables(), 'ws_1', { + folderIds: new Set(['folder_sales']), + }) + + expect(blockers).toHaveLength(1) + expect(blockers[0]?.targetTableName).toBe('Customers') + }) +}) + +describe('tableReferenceBlockerMessage', () => { + it('shows the target and every table preventing deletion', () => { + expect(tableReferenceBlockerMessage('Customers', ['Orders', 'Invoices'])).toBe( + 'Cannot delete table "Customers" because it is referenced by tables "Invoices", "Orders". Remove the reference columns first.' + ) + }) +}) diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts index afc0c2f4a05..0bb7c23e5d9 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -12,8 +12,9 @@ */ import { userTableDefinitions, userTableRows } from '@sim/db/schema' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types/registry' import type { ColumnType } from '@/lib/table/column-types/types' import type { @@ -22,7 +23,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 { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types' +import type { ColumnDefinition, JsonValue, SelectOption, TableSchema } from '@/lib/table/types' /** * Rewrites a column's cells from stored option **ids** to option **names**, for @@ -295,9 +296,126 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record typeof column.referenceTableId === 'string' ? [column.referenceTableId] : [], + remapReferencedTableIds: (column, tableIdMap) => { + const referenceTableId = column.referenceTableId + if (typeof referenceTableId !== 'string') return column + const remappedTableId = tableIdMap.get(referenceTableId) + return remappedTableId && remappedTableId !== referenceTableId + ? { ...column, referenceTableId: remappedTableId } + : column + }, }, } +/** Collects the distinct table IDs named by type-specific column metadata. */ +export function collectColumnReferencedTableIds(columns: readonly ColumnDefinition[]): string[] { + return [ + ...new Set( + columns.flatMap( + (column) => COLUMN_TYPE_SERVER_REGISTRY[column.type].referencedTableIds?.(column) ?? [] + ) + ), + ] +} + +export interface ActiveTableReferenceBlocker { + targetTableId: string + targetTableName: string + targetFolderId: string | null + referencingTableId: string + referencingTableName: string +} + +/** Builds the caller-facing conflict shown for a referenced table deletion. */ +export function tableReferenceBlockerMessage( + targetTableName: string, + referencingTableNames: readonly string[] +): string { + const names = [...new Set(referencingTableNames)].sort().map((name) => `"${name}"`) + const blockerLabel = names.length === 1 ? 'table' : 'tables' + const columnLabel = names.length === 1 ? 'column' : 'columns' + return `Cannot delete table "${targetTableName}" because it is referenced by ${blockerLabel} ${names.join(', ')}. Remove the reference ${columnLabel} first.` +} + +/** + * Finds active tables that point at any active table in the requested deletion selection. + * + * The table service caps the number of tables in a workspace, so reading the active definitions + * once is bounded and cheaper than issuing one JSONB search per table in a folder cascade. + * Reference ownership still comes from the server column-type registry; this function does not + * duplicate knowledge of the `reference` column shape. + */ +export async function findActiveTableReferenceBlockers( + executor: DbOrTx, + workspaceId: string, + selection: { tableIds?: readonly string[]; folderIds?: ReadonlySet } +): Promise { + const selectedTableIds = new Set(selection.tableIds) + const selectedFolderIds = selection.folderIds ?? new Set() + if (selectedTableIds.size === 0 && selectedFolderIds.size === 0) return [] + + const activeTables = await executor + .select({ + id: userTableDefinitions.id, + name: userTableDefinitions.name, + folderId: userTableDefinitions.folderId, + schema: userTableDefinitions.schema, + }) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + + const selectedTargets = new Map( + activeTables + .filter( + (table) => + selectedTableIds.has(table.id) || + (table.folderId !== null && selectedFolderIds.has(table.folderId)) + ) + .map((table) => [table.id, table]) + ) + if (selectedTargets.size === 0) return [] + + const blockers: ActiveTableReferenceBlocker[] = [] + for (const referencingTable of activeTables) { + for (const referencedTableId of collectColumnReferencedTableIds( + (referencingTable.schema as TableSchema).columns + )) { + const target = selectedTargets.get(referencedTableId) + if (!target) continue + blockers.push({ + targetTableId: target.id, + targetTableName: target.name, + targetFolderId: target.folderId, + referencingTableId: referencingTable.id, + referencingTableName: referencingTable.name, + }) + } + } + + return blockers.sort( + (left, right) => + left.targetTableName.localeCompare(right.targetTableName) || + left.referencingTableName.localeCompare(right.referencingTableName) + ) +} + +/** Rewrites every table reference owned by a registered column type. */ +export function remapColumnReferencedTableIds( + columns: readonly ColumnDefinition[], + tableIdMap: ReadonlyMap +): ColumnDefinition[] { + return columns.map( + (column) => + COLUMN_TYPE_SERVER_REGISTRY[column.type].remapReferencedTableIds?.(column, tableIdMap) ?? + column + ) +} + /** * Validates every table ID referenced by column metadata in one query. * @@ -307,16 +425,21 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record } ): Promise { - const referencedTableIds = [ - ...new Set( - columns.flatMap( - (column) => COLUMN_TYPE_SERVER_REGISTRY[column.type].referencedTableIds?.(column) ?? [] - ) - ), - ] + const referencedTableIds = collectColumnReferencedTableIds(columns) if (referencedTableIds.length === 0) return + const allowedArchivedTableIds = referencedTableIds.filter((id) => + options?.allowedArchivedTableIds?.has(id) + ) + const availableTarget = + allowedArchivedTableIds.length > 0 + ? or( + isNull(userTableDefinitions.archivedAt), + inArray(userTableDefinitions.id, allowedArchivedTableIds) + ) + : isNull(userTableDefinitions.archivedAt) const targets = await trx .select({ id: userTableDefinitions.id }) @@ -325,9 +448,10 @@ export async function assertColumnReferencesInWorkspace( and( eq(userTableDefinitions.workspaceId, workspaceId), inArray(userTableDefinitions.id, referencedTableIds), - isNull(userTableDefinitions.archivedAt) + availableTarget ) ) + .for('key share') const foundIds = new Set(targets.map((target) => target.id)) const missingId = referencedTableIds.find((id) => !foundIds.has(id)) if (missingId) { diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts index b569c73a0ea..6bc42ad2377 100644 --- a/apps/sim/lib/table/column-types/types.server.ts +++ b/apps/sim/lib/table/column-types/types.server.ts @@ -37,6 +37,14 @@ export interface ColumnTypeServerDefinition { * a schema is persisted. Omitted by types that do not reference tables. */ readonly referencedTableIds?: (column: ColumnDefinition) => readonly string[] + /** + * Rewrites this column's table references through a source-to-target identity map. + * Omitted by types that do not reference tables. + */ + readonly remapReferencedTableIds?: ( + column: ColumnDefinition, + tableIdMap: ReadonlyMap + ) => ColumnDefinition /** * 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 807b0c3e6ce..6db94037a07 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -75,6 +75,12 @@ export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number] /** Result of coercing a raw value toward a column's declared type. */ export type CoerceResult = { ok: true; value: JsonValue } | { ok: false } +/** Client-side behavior for a column whose stored value can open a referenced row preview. */ +export interface ColumnReferencePreviewDefinition { + getTableId(column: ColumnDefinition): string | undefined + getRowId(value: unknown): string | null +} + export interface ColumnTypeDefinition { readonly id: ColumnType @@ -141,6 +147,8 @@ export interface ColumnTypeDefinition { * bounded, structured value. */ readonly expandable: boolean + /** Optional inline referenced-row presentation owned by this column type. */ + readonly referencePreview?: ColumnReferencePreviewDefinition /** `inputMode` for the text editor, when the type wants a specific keypad. */ readonly inputMode?: 'decimal' /** diff --git a/apps/sim/lib/table/columns/reference-metadata.test.ts b/apps/sim/lib/table/columns/reference-metadata.test.ts index 2d66d297e33..fa61729fd91 100644 --- a/apps/sim/lib/table/columns/reference-metadata.test.ts +++ b/apps/sim/lib/table/columns/reference-metadata.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ migrationFrom: vi.fn(), migrationTo: vi.fn(), writeBackCoercedCells: vi.fn(), + assertTableReferenceColumnsEnabled: vi.fn(), set: vi.fn(), where: vi.fn(), })) @@ -23,6 +24,9 @@ vi.mock('@/lib/table/column-types/registry.server', () => ({ migrationTo: mocks.migrationTo, writeBackCoercedCells: mocks.writeBackCoercedCells, })) +vi.mock('@/lib/table/reference-columns/availability', () => ({ + assertTableReferenceColumnsEnabled: mocks.assertTableReferenceColumnsEnabled, +})) import { addTableColumn, @@ -64,6 +68,7 @@ describe('reference column metadata persistence', () => { 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 }) }) @@ -108,6 +113,55 @@ describe('reference column metadata persistence', () => { ) }) + 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) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 0993f9b1727..06566c5c4e9 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -39,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' @@ -136,6 +137,7 @@ export async function addTableColumn( options?: ColumnMutationOptions ): Promise { if (column.type === 'ttl') await assertTableRowTtlEnabled() + if (column.type === 'reference') await assertTableReferenceColumnsEnabled() return withLockedTable( tableId, @@ -868,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, @@ -1491,6 +1494,8 @@ export async function updateColumnReference( requestId: string, options?: ColumnMutationOptions ): Promise { + await assertTableReferenceColumnsEnabled() + return withLockedTable( data.tableId, async (table, trx) => { diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 6d9f979c17f..50ea7cfc3b2 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -512,8 +512,6 @@ export function coerceValue( return String(value) } } - case 'reference': - return String(value) default: return String(value) } 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 00ae4102236..3d623028d69 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -14,11 +14,24 @@ import type { TableSchema } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ assertColumnReferencesInWorkspace: vi.fn(), + findActiveTableReferenceBlockers: vi.fn(), + assertTableReferenceColumnsEnabled: vi.fn(), + getWorkspaceWithOwner: vi.fn(), + tableReferenceBlockerMessage: vi.fn( + (target: string, blockers: string[]) => + `Cannot delete table "${target}" because it is referenced by table "${blockers[0]}". Remove the reference column first.` + ), assertTableRowTtlEnabled: vi.fn(), })) vi.mock('@/lib/table/column-types/registry.server', () => ({ assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace, + findActiveTableReferenceBlockers: mocks.findActiveTableReferenceBlockers, + tableReferenceBlockerMessage: mocks.tableReferenceBlockerMessage, +})) + +vi.mock('@/lib/table/reference-columns/availability', () => ({ + assertTableReferenceColumnsEnabled: mocks.assertTableReferenceColumnsEnabled, })) vi.mock('@/lib/realtime/notify', () => ({ @@ -30,11 +43,21 @@ vi.mock('@/lib/table/billing', () => ({ notifyTableRowUsage: vi.fn(), })) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: mocks.getWorkspaceWithOwner, +})) + vi.mock('@/lib/table/ttl-availability', () => ({ assertTableRowTtlEnabled: mocks.assertTableRowTtlEnabled, })) -import { createTable, getTableById } from '@/lib/table/service' +import { + createTable, + deleteTable, + getTableById, + restoreTable, + TableReferencedError, +} from '@/lib/table/service' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' @@ -73,6 +96,7 @@ describe('createTable schema invariants', () => { resetDbChainMock() mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) mocks.assertTableRowTtlEnabled.mockResolvedValue(undefined) + mocks.assertTableReferenceColumnsEnabled.mockResolvedValue(undefined) }) it('rejects a TTL schema before persistence when the feature is disabled', async () => { @@ -160,6 +184,24 @@ describe('createTable schema invariants', () => { ) }) + 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' }) @@ -348,3 +390,126 @@ describe('getTableById job derivation', () => { expect(dbChainMockFns.select).not.toHaveBeenCalled() }) }) + +describe('restoreTable reference validation', () => { + const referenceSchema = { + columns: [ + { + id: 'col_account', + name: 'account', + type: 'reference', + referenceTableId: 'tbl_accounts', + }, + ], + } as TableSchema + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.assertColumnReferencesInWorkspace.mockResolvedValue(undefined) + mocks.getWorkspaceWithOwner.mockResolvedValue({ id: WORKSPACE_ID, archivedAt: null }) + }) + + it('validates targets under the row lock and admits the restore cohort', async () => { + const archived = definitionRow({ + archivedAt: new Date('2026-01-03T00:00:00Z'), + schema: referenceSchema, + }) + queueTableRows(schemaMock.userTableDefinitions, [archived]) + queueTableRows(schemaMock.userTableDefinitions, [archived]) + queueTableRows(schemaMock.userTableDefinitions, []) + const restoringTableIds = new Set(['tbl_accounts']) + + await restoreTable(TABLE_ID, 'request-1', { restoringTableIds }) + + expect(mocks.assertColumnReferencesInWorkspace).toHaveBeenCalledWith( + expect.anything(), + WORKSPACE_ID, + referenceSchema.columns, + { allowedArchivedTableIds: new Set(['tbl_accounts', TABLE_ID]) } + ) + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.userTableDefinitions) + }) + + it('leaves the table archived when a reference target is unavailable', async () => { + const archived = definitionRow({ + archivedAt: new Date('2026-01-03T00:00:00Z'), + schema: referenceSchema, + }) + queueTableRows(schemaMock.userTableDefinitions, [archived]) + queueTableRows(schemaMock.userTableDefinitions, [archived]) + mocks.assertColumnReferencesInWorkspace.mockRejectedValueOnce({ code: 'not_found' }) + + await expect(restoreTable(TABLE_ID, 'request-1')).rejects.toMatchObject({ code: 'not_found' }) + + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) + +describe('deleteTable reference guard', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.findActiveTableReferenceBlockers.mockResolvedValue([]) + }) + + const activeTable = { + name: 'Customers', + archivedAt: null, + deleteLocked: false, + workspaceId: WORKSPACE_ID, + } + + it('archives an unreferenced table inside the guarded transaction', async () => { + queueTableRows(schemaMock.userTableDefinitions, [activeTable]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { name: 'Customers', workspaceId: WORKSPACE_ID }, + ]) + + await expect(deleteTable('tbl_customers', 'request-1')).resolves.toEqual({ + archived: { name: 'Customers', workspaceId: WORKSPACE_ID }, + }) + + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + expect(mocks.findActiveTableReferenceBlockers).toHaveBeenCalledWith( + expect.anything(), + WORKSPACE_ID, + { tableIds: ['tbl_customers'] } + ) + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + }) + + it('blocks deletion and names the table holding the reference', async () => { + queueTableRows(schemaMock.userTableDefinitions, [activeTable]) + mocks.findActiveTableReferenceBlockers.mockResolvedValueOnce([ + { + targetTableId: 'tbl_customers', + targetTableName: 'Customers', + targetFolderId: null, + referencingTableId: 'tbl_orders', + referencingTableName: 'Orders', + }, + ]) + + await expect(deleteTable('tbl_customers', 'request-1')).rejects.toEqual( + expect.objectContaining({ + name: 'TableReferencedError', + code: 'conflict', + message: + 'Cannot delete table "Customers" because it is referenced by table "Orders". Remove the reference column first.', + }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(TableReferencedError).toBeTypeOf('function') + }) + + it('keeps the existing delete-lock verdict ahead of the reference check', async () => { + queueTableRows(schemaMock.userTableDefinitions, [{ ...activeTable, deleteLocked: true }]) + + await expect(deleteTable('tbl_customers', 'request-1')).rejects.toMatchObject({ + name: 'TableLockedError', + lock: 'delete', + }) + expect(mocks.findActiveTableReferenceBlockers).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index e0cd3bb9e52..cf1dd4890a3 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -36,7 +36,11 @@ 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 { + assertColumnReferencesInWorkspace, + findActiveTableReferenceBlockers, + tableReferenceBlockerMessage, +} from '@/lib/table/column-types/registry.server' import { COLUMN_TYPES, DEFAULT_TABLE_VIEW_NAME, @@ -53,6 +57,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, @@ -85,6 +90,14 @@ export class TableConflictError extends OrchestrationError { } } +/** A table still has one or more active inbound reference columns. */ +export class TableReferencedError extends OrchestrationError { + constructor(targetTableName: string, referencingTableNames: readonly string[]) { + super('conflict', tableReferenceBlockerMessage(targetTableName, referencingTableNames)) + this.name = 'TableReferencedError' + } +} + export type TableScope = 'active' | 'archived' | 'all' /** @@ -565,6 +578,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() @@ -1151,32 +1167,11 @@ export async function deleteTable( options?: { archivedAt?: Date; skipNotify?: boolean; expectedWorkspaceId?: string } ): Promise<{ archived: { name: string; workspaceId: string | null } | null }> { const now = options?.archivedAt ?? new Date() - // Archiving destroys access to every row, so it is gated on the delete lock. - // The guard is inline in the WHERE (atomic — no separate read, no TOCTOU); - // a zero-row result is then disambiguated below (locked vs already-archived). - const result = await db - .update(userTableDefinitions) - .set({ archivedAt: now, updatedAt: now }) - .where( - and( - eq(userTableDefinitions.id, tableId), - options?.expectedWorkspaceId - ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId) - : undefined, - isNull(userTableDefinitions.archivedAt), - eq(userTableDefinitions.deleteLocked, false) - ) - ) - .returning({ - createdBy: userTableDefinitions.createdBy, - workspaceId: userTableDefinitions.workspaceId, - name: userTableDefinitions.name, - }) - - const deleted = result[0] - if (!deleted) { - const [existing] = await db + const deleted = await db.transaction(async (trx) => { + await setTableTxTimeouts(trx) + const [existing] = await trx .select({ + name: userTableDefinitions.name, archivedAt: userTableDefinitions.archivedAt, deleteLocked: userTableDefinitions.deleteLocked, workspaceId: userTableDefinitions.workspaceId, @@ -1190,8 +1185,11 @@ export async function deleteTable( : undefined ) ) + .for('update') .limit(1) - if (existing && !existing.archivedAt && existing.deleteLocked) { + + if (!existing || existing.archivedAt) return null + if (existing.deleteLocked) { logger.warn('Table mutation blocked by lock', { tableId, workspaceId: existing.workspaceId, @@ -1199,8 +1197,36 @@ export async function deleteTable( }) throw new TableLockedError('delete') } - // Otherwise the table is missing or already archived — a silent no-op, as before. - } + + const blockers = existing.workspaceId + ? await findActiveTableReferenceBlockers(trx, existing.workspaceId, { + tableIds: [tableId], + }) + : [] + if (blockers.length > 0) { + throw new TableReferencedError( + existing.name, + blockers.map((blocker) => blocker.referencingTableName) + ) + } + + const [archived] = await trx + .update(userTableDefinitions) + .set({ archivedAt: now, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, tableId), + isNull(userTableDefinitions.archivedAt), + eq(userTableDefinitions.deleteLocked, false) + ) + ) + .returning({ + workspaceId: userTableDefinitions.workspaceId, + name: userTableDefinitions.name, + }) + return archived ?? null + }) + logger.info(`[${requestId}] Archived table ${tableId}`) // Live tables list: only on a genuine archive (a no-op/already-archived delete changes nothing). // Skipped under a folder cascade — deleteFolder fires one folder-level notify for the whole subtree, @@ -1225,7 +1251,11 @@ export async function deleteTable( export async function restoreTable( tableId: string, requestId: string, - options?: { restoringFolderIds?: ReadonlySet; skipNotify?: boolean } + options?: { + restoringFolderIds?: ReadonlySet + restoringTableIds?: ReadonlySet + skipNotify?: boolean + } ): Promise { const table = await getTableById(tableId, { includeArchived: true }) if (!table) { @@ -1271,6 +1301,17 @@ export async function restoreTable( await db.transaction(async (tx) => { await setTableTxTimeouts(tx) await tx.execute(sql`SELECT 1 FROM user_table_definitions WHERE id = ${tableId} FOR UPDATE`) + const currentTable = await getTableById(tableId, { tx, includeArchived: true }) + if (!currentTable) throw new OrchestrationError('not_found', 'Table not found') + + const allowedArchivedTableIds = new Set(options?.restoringTableIds) + allowedArchivedTableIds.add(tableId) + await assertColumnReferencesInWorkspace( + tx, + currentTable.workspaceId, + currentTable.schema.columns, + { allowedArchivedTableIds } + ) attemptedRestoreName = await generateRestoreName(table.name, async (candidate) => { const [match] = await tx 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, }, } }