diff --git a/apps/sim/app/api/table/names/route.test.ts b/apps/sim/app/api/table/names/route.test.ts new file mode 100644 index 00000000000..0348bcf9389 --- /dev/null +++ b/apps/sim/app/api/table/names/route.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ + +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + listNames: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/table/application/operations', () => ({ + tableOperations: { list: { id: 'tables.list' } }, +})) +vi.mock('@/lib/table/application/tables', () => ({ + listTableNamesUseCase: { operation: { id: 'tables.list' }, execute: mocks.listNames }, +})) + +import { POST } from '@/app/api/table/names/route' + +function request(body?: unknown) { + return createMockRequest('POST', body, {}, 'http://localhost/api/table/names') +} + +describe('POST /api/table/names', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.listNames.mockResolvedValue({ + tables: [{ id: 'table-1', name: 'Accounts' }], + }) + }) + + it('returns the lightweight table-name projection', async () => { + const response = await POST( + request({ workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] }), + {} + ) + + expect(response.status).toBe(200) + expect(mocks.listNames.mock.calls[0][0]).toMatchObject({ + principal: { kind: 'session', userId: 'user-1' }, + input: { workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] }, + }) + expect(await response.json()).toEqual({ + success: true, + data: { tables: [{ id: 'table-1', name: 'Accounts' }] }, + }) + }) + + it('authenticates before validating the body', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await POST(request(), {}) + + expect(response.status).toBe(401) + expect(mocks.listNames).not.toHaveBeenCalled() + }) + + it('rejects an empty table ID list', async () => { + const response = await POST(request({ workspaceId: 'workspace-1', tableIds: [] }), {}) + + expect(response.status).toBe(400) + expect(mocks.listNames).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/table/names/route.ts b/apps/sim/app/api/table/names/route.ts new file mode 100644 index 00000000000..349c92dfea1 --- /dev/null +++ b/apps/sim/app/api/table/names/route.ts @@ -0,0 +1,22 @@ +import { listTableNamesContract } from '@/lib/api/contracts/tables' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { tableOperations } from '@/lib/table/application/operations' +import { listTableNamesUseCase } from '@/lib/table/application/tables' + +export const POST = defineInternalJsonRoute({ + contract: listTableNamesContract, + operation: tableOperations.list, + auth: internalSessionAuth, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal table list behavior', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: listTableNamesUseCase, + present: ({ tables }) => ({ success: true as const, data: { tables } }), +}) 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..6ba14c2cb4f 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,7 @@ interface CellContentProps { waitingOnLabels?: string[] /** Column is an enrichment output — a completed-but-empty cell renders "Not found". */ isEnrichmentOutput?: boolean + referenceAction?: ReferenceCellAction } /** @@ -43,12 +49,14 @@ export function CellContent({ workspaceId, timeZone, timezoneStatus, + referenceColumnsEnabled, isEditing, initialCharacter, onSave, onCancel, waitingOnLabels, isEnrichmentOutput, + referenceAction, }: CellContentProps) { const kind = resolveCellRender({ value, @@ -59,6 +67,7 @@ export function CellContent({ currentWorkspaceId: workspaceId, timeZone, timezoneStatus, + referenceColumnsEnabled, }) return ( @@ -74,7 +83,7 @@ export function CellContent({ /> )} - + > ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts index ab0789ff2d4..02017ddfafb 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts @@ -30,6 +30,7 @@ describe('resolveCellRender', () => { exec: undefined, column: column('ttl'), waitingOnLabels: undefined, + referenceColumnsEnabled: false, timeZone: 'America/New_York', }) ).toEqual({ kind: 'date', text: '2023-11-14T17:13:20-05:00' }) @@ -42,6 +43,7 @@ describe('resolveCellRender', () => { exec: undefined, column: column('ttl'), waitingOnLabels: undefined, + referenceColumnsEnabled: false, timeZone: 'America/Los_Angeles', timezoneStatus: 'invalid', }) @@ -55,6 +57,7 @@ describe('resolveCellRender', () => { exec: undefined, column: column('ttl'), waitingOnLabels: undefined, + referenceColumnsEnabled: false, timeZone: 'America/Los_Angeles', timezoneStatus: 'loading', }) @@ -68,6 +71,7 @@ describe('resolveCellRender', () => { exec: undefined, column: column('date'), waitingOnLabels: undefined, + referenceColumnsEnabled: false, timeZone: 'America/Los_Angeles', timezoneStatus: 'error', }) 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..1a27df72f2b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx @@ -0,0 +1,204 @@ +/** + * @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 + }) => ( + + {children} + + ), + 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, + referenceColumnsEnabled: true, + }) + ).toEqual({ kind: 'reference-chip', label: 'Accounts' }) + }) + + it('keeps an empty reference cell empty', () => { + expect( + resolveCellRender({ + value: '', + exec: undefined, + column: REFERENCE_COLUMN, + waitingOnLabels: undefined, + referenceColumnsEnabled: true, + }) + ).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, + referenceColumnsEnabled: true, + }) + ).toEqual({ kind: 'reference-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).toHaveProperty('dataset.referenceCellTrigger', '') + 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() + const onReferenceClick = vi.fn() + + act(() => { + root.render( + + + + ) + }) + + act(() => { + const chip = container.querySelector('button') + chip?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })) + chip?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 })) + chip?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, detail: 2 })) + }) + + expect(onReferenceClick).toHaveBeenCalledOnce() + 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..91565fb6b20 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: 'reference-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, }: ResolveCellRenderInput): CellRenderKind { const isNull = value === null || value === undefined const isEmpty = isNull || value === '' @@ -135,21 +138,33 @@ 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: 'reference-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 // (keyed on kind alone) no longer has. Renders as plain text — a currency // cell is a number cell with a symbol, so it stays left-aligned like one. if (column.type === 'currency') { - return { kind: 'text', text: columnTypeOf(column).formatForDisplay(value, column) } + return { kind: 'text', text: typeDefinition.formatForDisplay(value, column) } } if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } - const definition = columnTypeOf(column) - if (definition.editor === 'date') { + if (typeDefinition.editor === 'date') { if (timezoneStatus !== undefined && timezoneStatus !== 'ready') { return { kind: 'date', text: stringifyValue(value), raw: true } } - return { kind: 'date', text: definition.formatForInput(value, column, { timezone: timeZone }) } + return { + kind: 'date', + text: typeDefinition.formatForInput(value, column, { timezone: timeZone }), + } } if (column.type === 'string') { const text = stringifyValue(value) @@ -264,9 +279,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 +413,35 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) + case 'reference-chip': { + const chip = ( + + {kind.label} + + ) + if (!referenceAction) return chip + return ( + { + event.stopPropagation() + if (event.detail > 1) return + referenceAction.onClick() + }} + onDoubleClick={(event) => event.stopPropagation()} + > + {chip} + + ) + } + 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..08b1098f5db --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx @@ -0,0 +1,438 @@ +/** + * @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, + }, +})) + +vi.mock('@/lib/table/column-types', () => ({ + columnTypeById: () => ({ icon: () => null }), + columnTypeOf: (column: { type: string; referenceTableId?: string }) => ({ + referencePreview: + column.type === 'reference' + ? { + getTableId: () => column.referenceTableId, + } + : undefined, + }), +})) + +vi.mock('@sim/emcn/icons', () => ({ + Loader: ({ animate }: { animate?: boolean }) => ( + + ), + 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 previewStatus: 'loading' | 'error' | 'missing' | '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' }, + }) + previewStatus = '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() { + if (previewStatus === 'ready' && !previewTable) { + throw new Error('Ready preview fixture requires a table') + } + const previewState = + previewStatus === 'ready' + ? ({ status: 'ready', table: previewTable, row: previewQuery.data ?? null } as const) + : ({ status: previewStatus } as const) + 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 only a loading state until the referenced schema and row are ready', () => { + previewStatus = 'loading' + previewTable = undefined + previewQuery.data = undefined + + renderPreview() + + expect(container.querySelector('[data-testid="reference-preview-loader"]')).not.toBeNull() + expect(container.querySelector('[role="table"]')).toBeNull() + expect(container.textContent).not.toContain('Table unavailable') + }) + + 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).toHaveProperty('dataset.referenceCellTrigger', '') + 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', () => { + previewStatus = 'error' + + renderPreview() + + expect(container.textContent).toContain("Couldn't load reference") + expect(container.textContent).not.toContain('No matching row') + expect(container.querySelector('[data-testid="reference-preview-loader"]')).toBeNull() + }) + + it('shows a not-found state when the referenced table no longer exists', () => { + previewStatus = 'missing' + previewTable = undefined + previewQuery.data = undefined + + renderPreview() + + expect(container.textContent).toContain('Table not found') + expect(container.textContent).not.toContain("Couldn't load reference") + expect(container.querySelector('[role="table"]')).toBeNull() + expect(container.querySelector('a[aria-label="Go to table"]')).toBeNull() + }) + + 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('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..d5fdab9b6fc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx @@ -0,0 +1,248 @@ +'use client' + +import { memo, type ReactNode, useLayoutEffect, useMemo, useRef } from 'react' +import { buttonVariants } from '@sim/emcn' +import { Loader, SquareArrowUpRight } from '@sim/emcn/icons' +import { noop } from '@sim/utils/helpers' +import Link from 'next/link' +import type { GetTableRowResponse } from '@/lib/api/contracts/tables' +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 } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils' +import type { TimezoneState } from '@/hooks/queries/general-settings' + +/** + * 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 ReferenceRowPreviewBaseProps { + workspaceId: string + timeZone: string + timezoneStatus: TimezoneState['status'] + referenceColumnsEnabled: boolean + referenceTableId: string + referenceTableNames: ReadonlyMap + colSpan: number +} + +type ReferenceRowPreviewProps = ReferenceRowPreviewBaseProps & + ( + | { status: 'loading' | 'error' | 'missing' } + | { + status: 'ready' + table: TableDefinition + row: GetTableRowResponse['data']['row'] | null + } + ) + +export const ReferenceRowPreview = memo(function ReferenceRowPreview( + props: ReferenceRowPreviewProps +) { + const { + workspaceId, + timeZone, + timezoneStatus, + referenceColumnsEnabled, + referenceTableId, + status, + referenceTableNames, + colSpan, + } = props + const table = status === 'ready' ? props.table : undefined + const row = status === 'ready' ? props.row : undefined + 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) + }, [status]) + + let content: ReactNode + 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} + + + ))} + + + + {!row ? ( + + No matching row + + ) : ( + <> + {columns.map((column) => ( + + + + + + ))} + + > + )} + + + ) + } + + return ( + + + + + {status === 'loading' ? ( + + + + ) : status === 'error' || status === 'missing' ? ( + + {status === 'missing' ? 'Table not found' : "Couldn't load reference"} + + ) : ( + <> + + + {table?.name} + + + + + + + {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 82457145a8b..ba5d4bdc4ef 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,14 @@ 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 type { + DisplayColumn, + ReferencePreviewTarget, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' 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 +51,8 @@ import { useDeleteColumn, useDeleteWorkflowGroup, useFindTableRows, + useReferenceRowPreview, + useTableNames, useTableRunState, useUpdateColumn, useUpdateTableMetadata, @@ -68,7 +78,6 @@ 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 { buildHeaderGroups, buildTableSelectionContext, @@ -84,6 +93,7 @@ import { expandToDisplayColumns, horizontalEdgeScrollVelocity, isCellInSelection, + isSameReferencePreviewTarget, moveCell, ROW_SELECTION_ALL, ROW_SELECTION_NONE, @@ -600,6 +610,24 @@ export function TableGrid({ // (and one the server rejects outright). filter: effectiveFilter, } = useTable({ workspaceId, tableId, queryOptions }) + const referencedTableIds = useMemo( + () => + referenceColumnsEnabled + ? columns.flatMap((column) => { + const referenceTableId = columnTypeOf(column).referencePreview?.getTableId(column) + return referenceTableId ? [referenceTableId] : [] + }) + : [], + [columns, referenceColumnsEnabled] + ) + const { data: referencedTables } = useTableNames(workspaceId, referencedTableIds) + const referenceTableNames = useMemo(() => { + const names = new Map() + for (const table of referencedTables ?? []) { + names.set(table.id, table.name) + } + return names + }, [referencedTables]) /** Sort is single-column, so only the first spec entry can be active. */ const activeSort = queryOptions.sort?.[0] @@ -658,19 +686,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 @@ -904,8 +920,63 @@ 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 activeReferenceTarget = 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 + if (!sourceRow || !sourceColumn || !referencePreview) return null + return referencePreview.getRowId(sourceRow.data[expandedReference.sourceColumnKey]) === + expandedReference.referenceRowId && + referencePreview.getTableId(sourceColumn) === expandedReference.referenceTableId + ? expandedReference + : null + }, [displayColumns, rows, expandedReference, referenceColumnsEnabled]) + const referencePreviewQuery = useReferenceRowPreview({ + workspaceId, + tableId: activeReferenceTarget?.referenceTableId, + rowId: activeReferenceTarget?.referenceRowId, + sourceRowId: activeReferenceTarget?.sourceRowId, + sourceColumnKey: activeReferenceTarget?.sourceColumnKey, + }) + const previewReferenceTableNames = useMemo(() => { + const names = new Map(referenceTableNames) + for (const table of referencePreviewQuery.data?.referenceTables ?? []) { + names.set(table.id, table.name) + } + return names + }, [referenceTableNames, referencePreviewQuery.data?.referenceTables]) + const referencePreviewState = referencePreviewQuery.isError + ? ({ status: 'error' } as const) + : referencePreviewQuery.isFetching || !referencePreviewQuery.data + ? ({ status: 'loading' } as const) + : referencePreviewQuery.data.table === null + ? ({ status: 'missing' } as const) + : ({ + status: 'ready', + table: referencePreviewQuery.data.table, + row: referencePreviewQuery.data.row, + } as const) + const expandedSourceRowId = activeReferenceTarget?.sourceRowId ?? null + + 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), @@ -2756,6 +2827,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) @@ -2872,8 +2951,15 @@ 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 as HTMLElement + const tag = target.tagName + if ( + tag === 'INPUT' || + tag === 'TEXTAREA' || + tag === 'SELECT' || + target.closest('[data-reference-cell-trigger]') + ) + return if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) { e.preventDefault() @@ -4707,7 +4793,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 @@ -4973,50 +5059,70 @@ export function TableGrid({ const index = virtualRow.index const row = rows[index] if (!row) return null + const rowReferenceTarget = + activeReferenceTarget?.sourceRowId === row.id + ? activeReferenceTarget + : null return ( - 0 ? pinnedOffsets : undefined} - lastPinnedColKey={lastPinnedColKey} - findMatchColumns={findMatchColumnsByRowId.get(row.id)} - /> + + 0 ? pinnedOffsets : undefined} + lastPinnedColKey={lastPinnedColKey} + findMatchColumns={findMatchColumnsByRowId.get(row.id)} + expandedReference={rowReferenceTarget} + onReferenceClick={handleReferenceClick} + /> + {rowReferenceTarget ? ( + + ) : 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..5e793ab8ee0 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,7 @@ export interface ColumnSourceInfo { export interface DisplayColumn extends ColumnDefinition { /** Stable per-visual-column identifier (= column.name). */ key: string + 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 +36,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..e4fc58c8bf6 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 @@ -13,7 +13,9 @@ import { canWriteRowsWithChip, chipRowCount, drainTargetForChip, + expandToDisplayColumns, horizontalEdgeScrollVelocity, + isSameReferencePreviewTarget, selectedColumnIds, } from './utils' @@ -26,6 +28,42 @@ 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('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..1198a7d95f5 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,18 @@ export type RowSelection = export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } +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 @@ -157,6 +173,14 @@ export type HeaderGroup = workflowId: string } +function resolveReferenceTableName( + column: ColumnDefinition, + referenceTableNames: ReadonlyMap | undefined +): string | undefined { + const tableId = columnTypeOf(column).referencePreview?.getTableId(column) + return tableId ? referenceTableNames?.get(tableId) : undefined +} + /** * Flat schema → one DisplayColumn per ColumnDefinition. Pre-pass computes * `groupSize` and `groupStartColIndex` for every consecutive run of columns @@ -165,7 +189,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 +219,7 @@ export function expandToDisplayColumns( out.push({ ...child, key: getColumnId(child), + referenceTableName: resolveReferenceTableName(child, referenceTableNames), outputBlockId: output?.blockId, outputPath: output?.path, groupSize: size, @@ -207,6 +233,7 @@ export function expandToDisplayColumns( out.push({ ...column, key: getColumnId(column), + referenceTableName: resolveReferenceTableName(column, referenceTableNames), groupSize: 1, groupStartColIndex: out.length, headerLabel: column.name, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index c36f87792e8..b9766ed9c3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -475,12 +475,16 @@ export function useTableEventStream({ // invalidateTableSchema set — the definition (exact, so rows stay on the // debounce), the run-state + enrichment sibling queries under detail (a group // delete/restructure can otherwise leave a stale running badge or enrichment - // panel), the tables list (column/row counts), and the debounced rows. + // panel), the tables list (column/row counts), open reference previews, and the + // debounced rows. else if (entry.event?.kind === 'schema') { void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) }) void queryClient.invalidateQueries({ queryKey: tableKeys.enrichmentDetails(tableId) }) void queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + void queryClient.invalidateQueries({ + queryKey: tableKeys.referencePreviewsForTable(tableId), + }) scheduleRowsInvalidate() } // A collaborator changed the column layout (width/pin/order): refetch the diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 3f90d87d679..f81dd9f9100 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -51,6 +51,8 @@ import { } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state' import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' +import { useReferencedByWarning } from '@/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning' +import { useWorkspaceTablesRoom } from '@/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room' import { useLogByExecutionId } from '@/hooks/queries/logs' import { downloadExportResult, @@ -197,6 +199,7 @@ export function Table({ const tableId = propTableId || (params.tableId as string) const hostContext = useOptionalWorkspaceHostContext() const referenceColumnsEnabled = hostContext?.features?.referenceColumns ?? false + useWorkspaceTablesRoom(workspaceId) const posthog = usePostHog() const tableRowTtlEnabled = useFeatureFlag('table-row-ttl') @@ -1400,6 +1403,8 @@ export function Table({ : 0 const deleteTableMutation = useDeleteTable(workspaceId) + const pendingDeleteTableIds = showDeleteTableConfirm ? [tableId] : [] + const referencedByWarning = useReferencedByWarning(workspaceId, pendingDeleteTableIds) const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId }) const exportTableAsync = useExportTable({ workspaceId, tableId }) const handleDeleteTable = async () => { @@ -1813,6 +1818,7 @@ export function Table({ { text: tableData?.name ?? 'this table', bold: true }, '? ', { text: `All ${tableData?.rowCount ?? 0} rows will be removed.`, error: true }, + ...referencedByWarning, ' You can restore it from Recently Deleted in Settings.', ]} confirm={{ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning.test.ts new file mode 100644 index 00000000000..16b84c3f594 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/hooks/queries/tables', () => ({ + useTablesList: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ + useOptionalWorkspaceHostContext: vi.fn(), +})) + +import { referencedByWarningText } from '@/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning' + +describe('referencedByWarningText', () => { + it('adds nothing when no surviving table references the deletion', () => { + expect(referencedByWarningText([])).toEqual([]) + }) + + it('names a single referencing table', () => { + expect(referencedByWarningText(['Orders'])).toEqual([ + ' Referenced by Orders. Those references will show as not found.', + ]) + }) + + it('joins referencing tables as a readable list', () => { + expect(referencedByWarningText(['Invoices', 'Orders'])).toEqual([ + ' Referenced by Invoices and Orders. Those references will show as not found.', + ]) + }) + + it('lists the first three names and summarizes the rest', () => { + expect(referencedByWarningText(['Accounts', 'Invoices', 'Leads', 'Orders', 'Quotes'])).toEqual([ + ' Referenced by Accounts, Invoices, Leads, and 2 more. Those references will show as not found.', + ]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning.ts b/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning.ts new file mode 100644 index 00000000000..57d4b13060e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning.ts @@ -0,0 +1,45 @@ +'use client' + +import type { ChipConfirmTextSegment } from '@sim/emcn' +import { findReferencingTables } from '@/lib/table/reference-columns/referrers' +import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { useTablesList } from '@/hooks/queries/tables' + +const MAX_LISTED_REFERRERS = 3 +const NAME_LIST_FORMAT = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }) +const NO_SEGMENTS: readonly ChipConfirmTextSegment[] = [] + +/** + * Confirmation copy naming the surviving tables that reference a pending deletion. Deletion is + * not blocked; the copy warns that those references will stop resolving. + */ +export function referencedByWarningText( + referrerNames: readonly string[] +): readonly ChipConfirmTextSegment[] { + if (referrerNames.length === 0) return NO_SEGMENTS + + const listed = referrerNames.slice(0, MAX_LISTED_REFERRERS) + const remaining = referrerNames.length - listed.length + const names = remaining > 0 ? [...listed, `${remaining} more`] : listed + + return [ + ` Referenced by ${NAME_LIST_FORMAT.format(names)}. Those references will show as not found.`, + ] +} + +/** + * Warning copy for the tables a delete confirmation would archive. Empty while Reference + * columns are disabled, nothing is pending, or no surviving table references the deletion. + */ +export function useReferencedByWarning( + workspaceId: string, + deletedTableIds: readonly string[] +): readonly ChipConfirmTextSegment[] { + const hostContext = useOptionalWorkspaceHostContext() + const enabled = (hostContext?.features?.referenceColumns ?? false) && deletedTableIds.length > 0 + const { data: tables } = useTablesList(workspaceId, 'active', { enabled }) + + if (!enabled || !tables) return NO_SEGMENTS + const referrers = findReferencingTables(tables, new Set(deletedTableIds)) + return referencedByWarningText(referrers.map((table) => table.name)) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room.ts b/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room.ts index 3387ede2b5f..707cc6a8683 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room.ts @@ -7,17 +7,16 @@ import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { tableKeys } from '@/hooks/queries/utils/table-keys' /** - * Keeps the tables browser live: joins the workspace-tables room so a `workspace-tables-changed` - * broadcast (fanned out by the table + table-folder mutation services) invalidates the tables list - * AND the table folders so every viewer refetches without waiting for staleness. A created/renamed/ - * moved/deleted/restored table changes the list result (including folder placement); a folder - * create/rename/delete/restore changes the folder tree — the page renders both, so both are - * invalidated. Thin binding over {@link useWorkspaceInvalidationRoom}. + * Table and table-folder mutations share this room because the browser renders both the table + * list and folder tree. Broadcast invalidation keeps every viewer current without waiting for + * query staleness. */ export function useWorkspaceTablesRoom(workspaceId: string): void { const queryClient = useQueryClient() useWorkspaceInvalidationRoom(workspaceId, ROOM_TYPES.WORKSPACE_TABLES, () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: tableKeys.namesRoot() }) + queryClient.invalidateQueries({ queryKey: tableKeys.referencePreviews() }) queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index b7876cd65f2..d435f79140e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -71,6 +71,7 @@ import { TablesListContextMenu, } from '@/app/workspace/[workspaceId]/tables/components' import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu' +import { useReferencedByWarning } from '@/app/workspace/[workspaceId]/tables/hooks/use-referenced-by-warning' import { useWorkspaceTablesRoom } from '@/app/workspace/[workspaceId]/tables/hooks/use-workspace-tables-room' import TablesLoading from '@/app/workspace/[workspaceId]/tables/loading' import { @@ -127,6 +128,21 @@ const ROOT_LABEL = FOLDERED_RESOURCE_HEADERS.table.rootLabel const EMPTY_TABLES: TableDefinition[] = [] +/** Tables inside `folderIds` or any folder nested beneath them. */ +function tableIdsInFolderSubtrees( + tables: readonly TableDefinition[], + folderIds: readonly string[], + descendantFolderIds: ReadonlyMap> +): string[] { + if (folderIds.length === 0) return [] + const coveredFolderIds = new Set( + folderIds.flatMap((folderId) => [folderId, ...(descendantFolderIds.get(folderId) ?? [])]) + ) + return tables.flatMap((table) => + table.folderId && coveredFolderIds.has(table.folderId) ? [table.id] : [] + ) +} + /** A list row (and the right-clicked row), resolved to the entity it refers to. */ type TableResourceItem = | { kind: 'table'; table: TableDefinition } @@ -595,6 +611,23 @@ export function Tables() { return selectionLabel(count, firstName) }, [selectedTableIds, selectedFolderIds, tables, folderById]) + const deleteFolderIds = + isDeleteFolderDialogOpen && activeFolder + ? [activeFolder.id] + : isBulkDeleteDialogOpen + ? selectedFolderIds + : [] + /** Tables the open delete confirmation would archive, including every table inside a folder. */ + const pendingDeleteTableIds = isDeleteDialogOpen + ? activeTable + ? [activeTable.id] + : [] + : [ + ...(isBulkDeleteDialogOpen ? selectedTableIds : []), + ...tableIdsInFolderSubtrees(tables, deleteFolderIds, descendantFolderIds), + ] + const referencedByWarning = useReferencedByWarning(workspaceId, pendingDeleteTableIds) + const currentFolderActions: DropdownOption[] | undefined = useMemo(() => { if (!currentFolderId) return undefined const folder = folderById.get(currentFolderId) @@ -1480,6 +1513,7 @@ export function Tables() { { text: activeTable?.name ?? 'this table', bold: true }, '? ', { text: `All ${activeTable?.rowCount ?? 0} rows will be removed.`, error: true }, + ...referencedByWarning, ' You can restore it from Recently Deleted in Settings.', ]} confirm={{ @@ -1503,6 +1537,7 @@ export function Tables() { { text: activeFolder?.name ?? 'this folder', bold: true }, '? ', { text: 'Every table and subfolder inside it will be deleted too.', error: true }, + ...referencedByWarning, ' You can restore those tables from Recently Deleted in Settings.', ]} confirm={{ @@ -1529,6 +1564,7 @@ export function Tables() { : 'All of their rows will be removed.', error: true, }, + ...referencedByWarning, ' You can restore those tables from Recently Deleted in Settings.', ]} confirm={{ diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index b6fcb8a963c..12f94e632d4 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -13,6 +13,7 @@ import { storageServiceMockFns, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MAX_FORK_RESOURCE_IDS_PER_TYPE } from '@/lib/api/contracts/workspace-fork' import { hashDurableSecretProvenanceValue } from '@/lib/execution/durable-secret-provenance' import { bindKnowledgeDocumentFieldSecretProvenance, @@ -175,6 +176,61 @@ describe('copyForkResourceContent', () => { 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,335 @@ 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('copies a table whose referenced table was deleted and keeps the original target', 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 inserted = new Map>>() + let definitionRead = 0 + const tx = { + select: () => ({ + from: (table: unknown) => ({ + where: () => { + if (table !== userTableDefinitions) return Promise.resolve([]) + return Promise.resolve(definitionRead++ === 0 ? [selectedDefinition] : []) + }, + }), + }), + 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') + expect(tableMap?.size).toBe(1) + expect(result.contentPlan.tables).toEqual([ + { sourceId: 'table-orders', childId: tableMap?.get('table-orders') }, + ]) + const copiedDefinitions = inserted.get(userTableDefinitions) + expect(copiedDefinitions).toHaveLength(1) + expect(copiedDefinitions?.[0]?.schema).toMatchObject({ + columns: [{ referenceTableId: 'table-accounts' }], + }) + }) + + 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_RESOURCE_IDS_PER_TYPE + 1 }, + (_, index) => `table-${index}` + ), + knowledgeBases: [], + }, + workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + ).rejects.toThrow( + `Cannot copy more than ${MAX_FORK_RESOURCE_IDS_PER_TYPE} 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..1ab92cb190f 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -35,6 +35,7 @@ import { type SQL, sql, } from 'drizzle-orm' +import { MAX_FORK_RESOURCE_IDS_PER_TYPE } from '@/lib/api/contracts/workspace-fork' import { decrementStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx, @@ -56,6 +57,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 +94,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') @@ -217,6 +223,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 +249,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 +307,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 +378,92 @@ 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. A deleted referenced table is not + * copied: the copied column keeps its original target, which resolves as not found, the same + * way the source workspace renders a reference to a deleted table. + */ +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_RESOURCE_IDS_PER_TYPE) { + throw new Error( + `Cannot copy more than ${MAX_FORK_RESOURCE_IDS_PER_TYPE} tables including referenced dependencies` + ) + } + const scheduledIds = new Set(orderedIds) + 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) { + 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_RESOURCE_IDS_PER_TYPE) { + throw new Error( + `Cannot copy more than ${MAX_FORK_RESOURCE_IDS_PER_TYPE} tables including referenced dependencies` + ) + } + scheduledIds.add(referencedId) + orderedIds.push(referencedId) + pendingIds.push(referencedId) + } + } + } + + 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 +732,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 +772,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 +844,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 +1326,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 +1380,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 +1433,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..e0887f32d85 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,13 @@ export function remapForkTableWorkflowGroups( return { ...schema, columns, workflowGroups: remappedGroups } } + +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/folders.ts b/apps/sim/hooks/queries/folders.ts index 8afbef3ed52..1c6396a6c70 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -128,7 +128,11 @@ function invalidateCascadedResourceLists( case 'workflow': return invalidateWorkflowLists(queryClient, workspaceId, ['active', 'archived']) case 'table': - return queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + return Promise.all([ + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }), + queryClient.invalidateQueries({ queryKey: tableKeys.namesRoot() }), + queryClient.invalidateQueries({ queryKey: tableKeys.referencePreviews() }), + ]).then(() => undefined) case 'knowledge_base': return queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) /** diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts index 0f81a28108f..6cb6e80aa7c 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 { 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]) }), + fetchQuery: vi.fn(), removeQueries: vi.fn(), }, } @@ -57,13 +60,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 { + getTableRowContract, + listTableNamesContract, + type TableViewWire, +} from '@/lib/api/contracts/tables' import { + TABLE_DETAIL_STALE_TIME, tableRowsInfiniteOptions, tableRowsParamsKey, + useBatchUpdateTableRows, useDeleteColumn, + useDeleteTableRow, + useDeleteTableRows, + useReferenceRowPreview, useRestoreTable, + useTableNames, useUpdateColumn, + useUpdateTableRow, useUpdateTableView, } from '@/hooks/queries/tables' import { tableKeys } from '@/hooks/queries/utils/table-keys' @@ -91,6 +107,349 @@ beforeEach(() => { vi.clearAllMocks() }) +describe('useTableNames', () => { + it('loads only the requested table names once with a canonical cache key', async () => { + vi.mocked(requestJson).mockResolvedValueOnce({ + success: true, + data: { tables: [{ id: TABLE_ID, name: 'Accounts' }] }, + }) + + useTableNames(WORKSPACE_ID, ['tbl-2', TABLE_ID, 'tbl-2']) + + const options = vi.mocked(useQuery).mock.calls.at(-1)?.[0] as { + enabled: boolean + queryKey: readonly unknown[] + queryFn: (context: { signal: AbortSignal }) => Promise + } + const signal = new AbortController().signal + await expect(options.queryFn({ signal })).resolves.toEqual([{ id: TABLE_ID, name: 'Accounts' }]) + expect(options).toMatchObject({ + enabled: true, + queryKey: tableKeys.names(WORKSPACE_ID, [TABLE_ID, 'tbl-2']), + }) + expect(options.queryKey.slice(0, tableKeys.namesRoot().length)).toEqual(tableKeys.namesRoot()) + expect(options.queryKey.slice(0, tableKeys.lists().length)).not.toEqual(tableKeys.lists()) + expect(requestJson).toHaveBeenCalledWith(listTableNamesContract, { + body: { workspaceId: WORKSPACE_ID, tableIds: [TABLE_ID, 'tbl-2'] }, + signal, + }) + }) + + it('does not fetch when there are no referenced tables', () => { + useTableNames(WORKSPACE_ID, []) + + const options = vi.mocked(useQuery).mock.calls.at(-1)?.[0] as { enabled: boolean } + expect(options.enabled).toBe(false) + }) +}) + +describe('useReferenceRowPreview', () => { + function getQueryOptions() { + return vi.mocked(useQuery).mock.calls.at(-1)?.[0] as { + enabled: boolean + gcTime: number + queryKey: readonly 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.fetchQuery.mockResolvedValueOnce(table) + vi.mocked(requestJson).mockResolvedValueOnce({ data: { row } }) + + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: row.id, + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + }) + + const options = getQueryOptions() + expect(options).toMatchObject({ + enabled: true, + gcTime: 0, + 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({ + table, + row, + referenceTables: [], + }) + expect(options).not.toHaveProperty('placeholderData') + expect(queryClient.fetchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: tableKeys.detail(TABLE_ID), + staleTime: TABLE_DETAIL_STALE_TIME, + }) + ) + expect(requestJson).toHaveBeenCalledOnce() + expect(requestJson).toHaveBeenCalledWith(getTableRowContract, { + params: { tableId: TABLE_ID, rowId: row.id }, + query: { workspaceId: WORKSPACE_ID }, + signal, + }) + }) + + it('loads nested reference table names in one request before resolving the preview', async () => { + const row = { id: 'row-1', data: { owner: 'owner-row-1' } } + const table = { + id: TABLE_ID, + name: 'Accounts', + schema: { + columns: [ + { + id: 'owner-1', + name: 'Owner', + type: 'reference', + referenceTableId: 'tbl-owners', + }, + { + id: 'owner-2', + name: 'Backup owner', + type: 'reference', + referenceTableId: 'tbl-owners', + }, + ], + }, + } + const referenceTables = [{ id: 'tbl-owners', name: 'Owners' }] + const signal = new AbortController().signal + queryClient.fetchQuery.mockResolvedValueOnce(table) + vi.mocked(requestJson) + .mockResolvedValueOnce({ data: { row } }) + .mockResolvedValueOnce({ success: true, data: { tables: referenceTables } }) + + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: row.id, + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + }) + + await expect(getQueryOptions().queryFn({ signal })).resolves.toEqual({ + table, + row, + referenceTables, + }) + expect(requestJson).toHaveBeenNthCalledWith(2, listTableNamesContract, { + body: { workspaceId: WORKSPACE_ID, tableIds: ['tbl-owners'] }, + signal, + }) + }) + + it('does not fetch until every referenced-row identity is available', () => { + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: undefined, + }) + + expect(getQueryOptions().enabled).toBe(false) + }) + + it('returns a null row when the referenced row no longer exists', async () => { + const table = { id: TABLE_ID, name: 'Accounts', schema: { columns: [] } } + queryClient.fetchQuery.mockResolvedValueOnce(table) + vi.mocked(requestJson).mockRejectedValueOnce({ status: 404 }) + vi.mocked(isApiClientError).mockReturnValueOnce(true) + + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: 'missing-row', + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + }) + + await expect( + getQueryOptions().queryFn({ signal: new AbortController().signal }) + ).resolves.toEqual({ + table, + row: null, + referenceTables: [], + }) + }) + + it('propagates non-not-found row errors', async () => { + const error = new Error('Failed to load row') + queryClient.fetchQuery.mockResolvedValueOnce({ + id: TABLE_ID, + name: 'Accounts', + schema: { columns: [] }, + }) + vi.mocked(requestJson).mockRejectedValueOnce(error) + + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: 'row-1', + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + }) + + await expect(getQueryOptions().queryFn({ signal: new AbortController().signal })).rejects.toBe( + error + ) + }) + + it('returns a not-found preview when the referenced table no longer exists', async () => { + queryClient.fetchQuery.mockRejectedValueOnce({ status: 404 }) + vi.mocked(requestJson).mockRejectedValueOnce({ status: 404 }) + vi.mocked(isApiClientError).mockReturnValueOnce(true).mockReturnValueOnce(true) + + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: 'row-1', + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + }) + + await expect( + getQueryOptions().queryFn({ signal: new AbortController().signal }) + ).resolves.toEqual({ + table: null, + row: null, + referenceTables: [], + }) + expect(requestJson).not.toHaveBeenCalledWith(listTableNamesContract, expect.anything()) + }) + + it('propagates non-not-found table errors', async () => { + const error = new Error('Failed to load table') + queryClient.fetchQuery.mockRejectedValueOnce(error) + vi.mocked(requestJson).mockResolvedValueOnce({ + success: true, + data: { row: { id: 'row-1', data: {} } }, + }) + + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: 'row-1', + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + }) + + await expect(getQueryOptions().queryFn({ signal: new AbortController().signal })).rejects.toBe( + error + ) + }) + + it('uses the source cell to identify each preview opening', () => { + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: 'row-1', + sourceRowId: 'source-row-1', + sourceColumnKey: 'account', + }) + const firstOpening = getQueryOptions().queryKey + + useReferenceRowPreview({ + workspaceId: WORKSPACE_ID, + tableId: TABLE_ID, + rowId: 'row-1', + sourceRowId: 'source-row-2', + sourceColumnKey: 'account', + }) + + expect(getQueryOptions().queryKey).not.toEqual(firstOpening) + }) +}) + +describe('useBatchUpdateTableRows', () => { + it('invalidates 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).toHaveBeenCalledOnce() + const options = queryClient.invalidateQueries.mock.calls[0]?.[0] + expect(options?.queryKey).toEqual(tableKeys.referencePreviewsForTable(TABLE_ID)) + expect( + options?.predicate({ + queryKey: tableKeys.referencePreview(TABLE_ID, 'row-1', 'source-row', 'account'), + }) + ).toBe(true) + expect( + options?.predicate({ + queryKey: tableKeys.referencePreview(TABLE_ID, 'row-3', 'source-row', 'account'), + }) + ).toBe(false) + expect( + tableKeys + .referencePreview('other-table', 'row-1', 'source-row', 'account') + .slice(0, options?.queryKey.length) + ).not.toEqual(options?.queryKey) + }) +}) + +describe('reference preview invalidation', () => { + function expectPreviewInvalidation(rowIds: string[]) { + const call = queryClient.invalidateQueries.mock.calls.find( + ([options]) => + JSON.stringify(options?.queryKey) === + JSON.stringify(tableKeys.referencePreviewsForTable(TABLE_ID)) + ) + 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 }) @@ -268,7 +627,7 @@ describe('useDeleteColumn optimistic update', () => { expect(getCache(ROWS_KEY)).toEqual(originalRows) }) - it('invalidates schema, rows, and lists in onSettled', () => { + it('invalidates schema, rows, lists, and mounted reference previews in onSettled', () => { const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) hook.onSettled?.(undefined, null, 'age', undefined) @@ -278,6 +637,7 @@ describe('useDeleteColumn optimistic update', () => { tableKeys.detail(TABLE_ID), tableKeys.rowsRoot(TABLE_ID), tableKeys.lists(), + tableKeys.referencePreviewsForTable(TABLE_ID), ]) ) }) @@ -336,6 +696,15 @@ describe('useUpdateColumn optimistic update', () => { ) expect(detail?.schema.columns[0]).toMatchObject({ id: 'age', name: 'years' }) }) + + it('invalidates mounted previews when the referenced table schema changes', () => { + const hook = useUpdateColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + hook.onSettled?.(undefined, null, { columnName: 'age', updates: { name: 'years' } }, undefined) + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: tableKeys.referencePreviewsForTable(TABLE_ID), + }) + }) }) describe('useRestoreTable cache invalidation', () => { @@ -362,7 +731,7 @@ describe('useRestoreTable cache invalidation', () => { }) }) - it('invalidates lists, table detail, and row data for the restored table', () => { + it('invalidates names, previews, lists, table detail, and row data for the restored table', () => { const hook = useRestoreTable() hook.onSettled?.(undefined, null, TABLE_ID, undefined) @@ -370,8 +739,10 @@ describe('useRestoreTable cache invalidation', () => { expect(calls).toEqual( expect.arrayContaining([ tableKeys.lists(), + tableKeys.namesRoot(), tableKeys.detail(TABLE_ID), tableKeys.rowsRoot(TABLE_ID), + tableKeys.referencePreviewsForTable(TABLE_ID), ]) ) }) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 22b4359b9f8..fcc8ad9a680 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -59,11 +59,14 @@ import { deleteTableViewContract, deleteWorkflowGroupContract, findTableRowsContract, + type GetTableRowResponse, getEnrichmentDetailContract, getTableContract, + getTableRowContract, type InsertTableRowBodyInput, listActiveDispatchesContract, listTableJobsContract, + listTableNamesContract, listTableRowsContract, listTablesContract, listTableViewsContract, @@ -108,6 +111,7 @@ import type { WorkflowGroupOutput, } 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, @@ -144,6 +148,8 @@ 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 +const TABLE_REFERENCE_PREVIEW_GC_TIME = 0 type TableRowsParams = Omit & TableIdParamsInput & { @@ -184,6 +190,22 @@ async function fetchTable( return response.data.table } +function normalizeTableIds(tableIds: readonly string[]) { + return [...new Set(tableIds)].sort() +} + +async function fetchTableNames( + workspaceId: string, + tableIds: readonly string[], + signal?: AbortSignal +) { + const response = await requestJson(listTableNamesContract, { + body: { workspaceId, tableIds: normalizeTableIds(tableIds) }, + signal, + }) + return response.data.tables +} + async function fetchTableRows({ workspaceId, tableId, @@ -217,12 +239,57 @@ 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.referencePreviewsForTable(tableId) + queryClient.invalidateQueries({ + queryKey: previewsRoot, + predicate: (query) => { + const targetRowId = query.queryKey[previewsRoot.length] + return typeof targetRowId === 'string' && rowIds.has(targetRowId) + }, + }) +} + +function invalidateTableNames(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: tableKeys.namesRoot() }) +} + +function invalidateReferenceTablePreviews( + queryClient: ReturnType, + tableId: string +) { + queryClient.invalidateQueries({ queryKey: tableKeys.referencePreviewsForTable(tableId) }) +} + /** * Invalidate only the row-count surfaces — the table detail and the tables * list, both of which carry the unfiltered `rowCount`. Deliberately leaves @@ -247,6 +314,7 @@ function invalidateTableSchema(queryClient: ReturnType, t queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateReferenceTablePreviews(queryClient, tableId) } /** @@ -264,6 +332,7 @@ function invalidateTableSchemaOnly( ) { queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateReferenceTablePreviews(queryClient, tableId) } /** @@ -301,6 +370,22 @@ export function useTablesList( }) } +export function useTableNames( + workspaceId: string | undefined, + referencedTableIds: readonly string[] +) { + const tableIds = normalizeTableIds(referencedTableIds) + return useQuery({ + queryKey: tableKeys.names(workspaceId, tableIds), + queryFn: async ({ signal }) => { + if (!workspaceId) throw new Error('Workspace ID required') + return fetchTableNames(workspaceId, tableIds, signal) + }, + enabled: Boolean(workspaceId && tableIds.length > 0), + staleTime: TABLE_LIST_STALE_TIME, + }) +} + /** * Fetch a single table by id. */ @@ -314,6 +399,60 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde }) } +interface ReferenceRowPreviewParams { + workspaceId: string | undefined + tableId: string | undefined + rowId: string | undefined + sourceRowId?: string + sourceColumnKey?: string +} + +/** Loads a referenced table and row together for an expanded source cell. */ +export function useReferenceRowPreview({ + workspaceId, + tableId, + rowId, + sourceRowId, + sourceColumnKey, +}: ReferenceRowPreviewParams) { + 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 + .fetchQuery({ + ...getTableDetailQueryOptions(workspaceId as string, tableId as string), + retry: (failureCount, error) => + !(isApiClientError(error) && error.status === 404) && failureCount < 1, + }) + .catch((error: unknown) => { + if (isApiClientError(error) && error.status === 404) return null + throw error + }), + fetchTableRow(workspaceId as string, tableId as string, rowId as string, signal), + ]) + if (!table) return { table: null, row: null, referenceTables: [] } + const referenceTableIds = table.schema.columns.flatMap((column) => { + const referenceTableId = columnTypeOf(column).referencePreview?.getTableId(column) + return referenceTableId ? [referenceTableId] : [] + }) + const referenceTables = + referenceTableIds.length === 0 + ? [] + : await fetchTableNames(workspaceId as string, referenceTableIds, signal) + return { table, row, referenceTables } + }, + enabled: Boolean(workspaceId && tableId && rowId && sourceRowId && sourceColumnKey), + 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. @@ -611,6 +750,7 @@ export function useCreateTable(workspaceId: string) { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateTableNames(queryClient) }, }) } @@ -660,6 +800,8 @@ export function useRenameTable(workspaceId: string) { onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: tableKeys.detail(variables.tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateTableNames(queryClient) + invalidateReferenceTablePreviews(queryClient, variables.tableId) }, }) } @@ -770,6 +912,8 @@ export function useDeleteTable(workspaceId: string) { }, onSettled: (_data, _error, tableId) => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateTableNames(queryClient) + invalidateReferenceTablePreviews(queryClient, tableId) queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) }) queryClient.removeQueries({ queryKey: tableKeys.rowsRoot(tableId) }) }, @@ -1154,6 +1298,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 +1375,10 @@ 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)) + invalidateReferencePreviews(queryClient, tableId, rowIds) + }, }) } @@ -1249,8 +1400,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 +1450,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)) }, }) } @@ -1826,8 +1979,12 @@ export function useRestoreTable() { onSettled: (_data, _error, tableId) => { return Promise.all([ queryClient.invalidateQueries({ queryKey: tableKeys.lists() }), + queryClient.invalidateQueries({ queryKey: tableKeys.namesRoot() }), queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }), queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }), + queryClient.invalidateQueries({ + queryKey: tableKeys.referencePreviewsForTable(tableId), + }), ]) }, }) @@ -1945,6 +2102,7 @@ export function useImportCsv() { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateTableNames(queryClient) }, }) } @@ -1989,6 +2147,7 @@ export function useImportFileAsTable() { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateTableNames(queryClient) }, }) } @@ -2656,6 +2815,8 @@ export function useBulkDeleteTables(workspaceId: string) { }, onSettled: (_data, _error, { tableIds = [] }) => { queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + invalidateTableNames(queryClient) + queryClient.invalidateQueries({ queryKey: tableKeys.referencePreviews() }) queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) for (const tableId of tableIds) { queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) }) diff --git a/apps/sim/hooks/queries/utils/table-keys.ts b/apps/sim/hooks/queries/utils/table-keys.ts index 5ccf7f34457..e6ab9635dc7 100644 --- a/apps/sim/hooks/queries/utils/table-keys.ts +++ b/apps/sim/hooks/queries/utils/table-keys.ts @@ -20,11 +20,19 @@ export const tableKeys = { lists: () => [...tableKeys.all, 'list'] as const, list: (workspaceId?: string, scope: TableQueryScope = 'active') => [...tableKeys.lists(), workspaceId ?? '', scope] as const, + namesRoot: () => [...tableKeys.all, 'names'] as const, + names: (workspaceId: string | undefined, tableIds: readonly string[]) => + [...tableKeys.namesRoot(), workspaceId ?? '', tableIds] as const, details: () => [...tableKeys.all, 'detail'] as const, detail: (tableId: string) => [...tableKeys.details(), tableId] as const, exportJobs: (workspaceId?: string) => [...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const, rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const, + referencePreviews: () => [...tableKeys.all, 'reference-preview'] as const, + referencePreviewsForTable: (tableId: string) => + [...tableKeys.referencePreviews(), tableId] as const, + referencePreview: (tableId: string, rowId: string, sourceRowId = '', sourceColumnKey = '') => + [...tableKeys.referencePreviewsForTable(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 4c95b3d6520..b8bc31762a1 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -936,6 +936,36 @@ export const listTablesContract = defineRouteContract({ }) export type ListTablesResponse = ContractJsonResponse +export const listTableNamesBodySchema = z.object({ + workspaceId: workspaceIdSchema, + tableIds: z + .array(referenceTableIdSchema) + .min(1, 'At least one table ID is required') + .max( + TABLE_LIMITS.MAX_COLUMNS_PER_TABLE, + `Cannot request more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} table names` + ), +}) +export type ListTableNamesBodyInput = z.input + +export const listTableNamesContract = defineRouteContract({ + method: 'POST', + path: '/api/table/names', + body: listTableNamesBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + tables: z.array( + z.object({ + id: z.string().min(1).max(MAX_ID_LENGTH), + name: tableNameSchema, + }) + ), + }) + ), + }, +}) export const createTableContract = defineRouteContract({ method: 'POST', path: '/api/table', @@ -1467,6 +1497,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/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index 49c7bdd25ca..6a10b7585b9 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -133,7 +133,9 @@ export type ForkLineageNodeApi = z.output export type ForkLineageChildApi = z.output export type GetForkLineageResponse = z.output -const forkResourceIdList = z.array(nonEmptyIdSchema).max(2000).optional() +export const MAX_FORK_RESOURCE_IDS_PER_TYPE = 2_000 + +const forkResourceIdList = z.array(nonEmptyIdSchema).max(MAX_FORK_RESOURCE_IDS_PER_TYPE).optional() export const forkResourceSelectionSchema = z.object({ files: forkResourceIdList, diff --git a/apps/sim/lib/table/application/tables.test.ts b/apps/sim/lib/table/application/tables.test.ts index 405b1c73056..b7463592d63 100644 --- a/apps/sim/lib/table/application/tables.test.ts +++ b/apps/sim/lib/table/application/tables.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ getTableById: vi.fn(), getLimits: vi.fn(), listDefinitions: vi.fn(), + listNames: vi.fn(), loadFolderIndex: vi.fn(), queryTables: vi.fn(), resolveArchivedContext: vi.fn(), @@ -49,6 +50,7 @@ vi.mock('@/lib/table', () => ({ deleteTable: vi.fn(), getTableById: mocks.getTableById, getWorkspaceTableLimits: mocks.getLimits, + listActiveTableNames: mocks.listNames, listTables: mocks.listDefinitions, moveTableToFolder: vi.fn(), queryTables: mocks.queryTables, @@ -82,6 +84,7 @@ vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal }) import { listTableDefinitionsUseCase, + listTableNamesUseCase, listTablesUseCase, readTableDefinitionUseCase, readTableDetailsUseCase, @@ -220,6 +223,7 @@ describe('internal table compatibility reads', () => { table: active, }) mocks.listDefinitions.mockResolvedValue([active]) + mocks.listNames.mockResolvedValue([{ id: active.id, name: active.name }]) mocks.getLimits.mockResolvedValue({ maxRowsPerTable: 2500 }) }) @@ -234,6 +238,17 @@ describe('internal table compatibility reads', () => { expect(mocks.loadFolderIndex).not.toHaveBeenCalled() }) + it('lists only active table names for lightweight display lookups', async () => { + const result = await listTableNamesUseCase.execute({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE.workspaceId, tableIds: ['table-1', 'table-2'] }, + }) + + expect(mocks.listNames).toHaveBeenCalledWith(WORKSPACE.workspaceId, ['table-1', 'table-2']) + expect(result.tables).toEqual([{ id: active.id, name: active.name }]) + expect(mocks.listDefinitions).not.toHaveBeenCalled() + }) + it('reads schema-only metadata without loading folders or plan limits', async () => { const result = await readTableDefinitionUseCase.execute({ principal: PRINCIPAL, diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 52974c43c4a..ee9886a89ad 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -15,6 +15,7 @@ import { deleteTable, getTableById, getWorkspaceTableLimits, + listActiveTableNames, listTables as listTableDefinitions, moveTableToFolder, queryTables, @@ -111,6 +112,20 @@ export const listTableDefinitionsUseCase = defineAuthorizedTableUseCase({ }, }) +export interface ListTableNamesInput { + workspaceId: string + tableIds: string[] +} + +export const listTableNamesUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.list, + resolveContext: ({ input }: { input: ListTableNamesInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + return { tables: await listActiveTableNames(context.workspaceId, input.tableIds) } + }, +}) + export interface CreateTableInput { workspaceId: string name: string 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.ts b/apps/sim/lib/table/column-types/registry.server.ts index afc0c2f4a05..392ffc7d2c6 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -295,9 +295,40 @@ 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 + }, }, } +/** Every distinct table ID named by the columns' type-specific metadata. */ +export function collectColumnReferencedTableIds(columns: readonly ColumnDefinition[]): string[] { + return [ + ...new Set( + columns.flatMap( + (column) => COLUMN_TYPE_SERVER_REGISTRY[column.type].referencedTableIds?.(column) ?? [] + ) + ), + ] +} + +/** Rewrites each column's table references through a source-to-target identity map. */ +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. * @@ -309,13 +340,7 @@ export async function assertColumnReferencesInWorkspace( workspaceId: string, columns: readonly ColumnDefinition[] ): 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 targets = await trx 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..addbd31e53a 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -75,6 +75,11 @@ 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 } +export interface ColumnReferencePreviewDefinition { + getTableId(column: ColumnDefinition): string | undefined + getRowId(value: unknown): string | null +} + export interface ColumnTypeDefinition { readonly id: ColumnType @@ -141,6 +146,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/reference-columns/availability.ts b/apps/sim/lib/table/reference-columns/availability.ts index d50c9a5327b..c7b18a659b1 100644 --- a/apps/sim/lib/table/reference-columns/availability.ts +++ b/apps/sim/lib/table/reference-columns/availability.ts @@ -9,7 +9,7 @@ export function areTableReferenceColumnsEnabled(): Promise { return isFeatureEnabled('table-reference-columns') } -/** Rejects mutations that introduce or reconfigure a Reference column. */ +/** Rejects operations that expose or mutate Reference-column behavior. */ export async function assertTableReferenceColumnsEnabled(): Promise { if (!(await areTableReferenceColumnsEnabled())) { throw new OrchestrationError('forbidden', TABLE_REFERENCE_COLUMNS_DISABLED_MESSAGE) diff --git a/apps/sim/lib/table/reference-columns/referrers.test.ts b/apps/sim/lib/table/reference-columns/referrers.test.ts new file mode 100644 index 00000000000..993ecc076cd --- /dev/null +++ b/apps/sim/lib/table/reference-columns/referrers.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { findReferencingTables } from '@/lib/table/reference-columns/referrers' +import type { ColumnDefinition, TableSchema } from '@/lib/table/types' + +function table(id: string, name: string, columns: ColumnDefinition[] = []) { + return { id, name, schema: { columns } as TableSchema } +} + +function reference(id: string, referenceTableId: string): ColumnDefinition { + return { id, name: id, type: 'reference', referenceTableId } +} + +describe('findReferencingTables', () => { + it('returns surviving tables that reference a deleted table, sorted by name', () => { + const tables = [ + table('tbl_accounts', 'Accounts'), + table('tbl_orders', 'Orders', [reference('col_account', 'tbl_accounts')]), + table('tbl_invoices', 'Invoices', [reference('col_account', 'tbl_accounts')]), + ] + + expect(findReferencingTables(tables, new Set(['tbl_accounts']))).toEqual([ + { id: 'tbl_invoices', name: 'Invoices' }, + { id: 'tbl_orders', name: 'Orders' }, + ]) + }) + + it('ignores referrers deleted in the same selection, including self-references', () => { + const tables = [ + table('tbl_accounts', 'Accounts', [reference('col_parent', 'tbl_accounts')]), + table('tbl_orders', 'Orders', [reference('col_account', 'tbl_accounts')]), + ] + + expect(findReferencingTables(tables, new Set(['tbl_accounts', 'tbl_orders']))).toEqual([]) + }) + + it('ignores other column types and references to surviving tables', () => { + const tables = [ + table('tbl_accounts', 'Accounts'), + table('tbl_companies', 'Companies'), + table('tbl_orders', 'Orders', [ + { id: 'col_note', name: 'note', type: 'string' }, + reference('col_company', 'tbl_companies'), + ]), + ] + + expect(findReferencingTables(tables, new Set(['tbl_accounts']))).toEqual([]) + }) + + it('returns nothing for an empty deletion', () => { + const tables = [table('tbl_orders', 'Orders', [reference('col_account', 'tbl_accounts')])] + + expect(findReferencingTables(tables, new Set())).toEqual([]) + }) +}) diff --git a/apps/sim/lib/table/reference-columns/referrers.ts b/apps/sim/lib/table/reference-columns/referrers.ts new file mode 100644 index 00000000000..a38005b2b7f --- /dev/null +++ b/apps/sim/lib/table/reference-columns/referrers.ts @@ -0,0 +1,26 @@ +import { columnTypeOf } from '@/lib/table/column-types' +import type { TableDefinition } from '@/lib/table/types' + +type ReferenceScanTable = Pick + +/** + * Tables outside a deletion whose Reference columns target a table inside it, sorted by name. + * Deleting a referenced table is allowed; its references resolve as not found afterwards. + */ +export function findReferencingTables( + tables: readonly ReferenceScanTable[], + deletedTableIds: ReadonlySet +): Array> { + if (deletedTableIds.size === 0) return [] + + const referencing: Array> = [] + for (const table of tables) { + if (deletedTableIds.has(table.id)) continue + const referencesDeletedTable = table.schema.columns.some((column) => { + const referenceTableId = columnTypeOf(column).referencePreview?.getTableId(column) + return referenceTableId !== undefined && deletedTableIds.has(referenceTableId) + }) + if (referencesDeletedTable) referencing.push({ id: table.id, name: table.name }) + } + return referencing.sort((left, right) => left.name.localeCompare(right.name)) +} diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts index 0250581ab55..952aa0b581a 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -39,10 +39,55 @@ vi.mock('@/lib/table/ttl-availability', () => ({ assertTableRowTtlEnabled: mocks.assertTableRowTtlEnabled, })) -import { createTable, getTableById } from '@/lib/table/service' +import { createTable, getTableById, listActiveTableNames } from '@/lib/table/service' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +describe('listActiveTableNames', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.assertTableReferenceColumnsEnabled.mockResolvedValue(undefined) + }) + + it('returns the name projection without loading table schemas', async () => { + queueTableRows(schemaMock.userTableDefinitions, [{ id: 'table-1', name: 'Accounts' }]) + + await expect(listActiveTableNames(WORKSPACE_ID, ['table-1', 'table-2'])).resolves.toEqual([ + { id: 'table-1', name: 'Accounts' }, + ]) + expect(dbChainMockFns.select).toHaveBeenCalledWith({ + id: schemaMock.userTableDefinitions.id, + name: schemaMock.userTableDefinitions.name, + }) + expect( + hasMockCondition( + dbChainMockFns.where.mock.calls[0][0], + (node) => + node.type === 'inArray' && + node.column === schemaMock.userTableDefinitions.id && + JSON.stringify(node.values) === JSON.stringify(['table-1', 'table-2']) + ) + ).toBe(true) + }) + + it('skips the database when no table IDs are requested', async () => { + await expect(listActiveTableNames(WORKSPACE_ID, [])).resolves.toEqual([]) + expect(mocks.assertTableReferenceColumnsEnabled).toHaveBeenCalledOnce() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('rejects name lookups before querying when reference columns are disabled', async () => { + mocks.assertTableReferenceColumnsEnabled.mockRejectedValueOnce({ code: 'forbidden' }) + + await expect(listActiveTableNames(WORKSPACE_ID, ['table-1'])).rejects.toMatchObject({ + code: 'forbidden', + }) + + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) + /** A column produced by a workflow group, and the group that declares it. */ function groupedSchema(overrides: { columnGroupId: string; groupId: string }): TableSchema { return { diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 2a1669b8453..a07e104f2fc 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -13,7 +13,7 @@ import { tableJobs, tableViews, userTableDefinitions, userTableRows } from '@sim import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, type Column, count, eq, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' +import { and, type Column, count, eq, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' import type { ListSortOrder } from '@/lib/api/list-query' import { @@ -360,6 +360,26 @@ export async function listTables( return hydrateTableRows(tables) } +/** Lists active table IDs and names without materializing their schemas. */ +export async function listActiveTableNames( + workspaceId: string, + tableIds: readonly string[] +): Promise>> { + await assertTableReferenceColumnsEnabled() + if (tableIds.length === 0) return [] + + return db + .select({ id: userTableDefinitions.id, name: userTableDefinitions.name }) + .from(userTableDefinitions) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + inArray(userTableDefinitions.id, [...tableIds]), + isNull(userTableDefinitions.archivedAt) + ) + ) +} + /** Loads at most two active exact-name matches so callers can fail on corrupt ambiguity. */ export async function findActiveTablesByExactName( workspaceId: string,