@@ -159,7 +165,7 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview(
role='cell'
className='flex min-w-full flex-1 items-center justify-center text-[var(--text-muted)]'
>
- No matching row
+ {NO_MATCHING_ROW}
) : (
<>
@@ -208,9 +214,9 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview(
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 585ea9237f6..95efc3a8e78 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
@@ -25,7 +25,11 @@ import type {
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
-import { columnTypeOf } from '@/lib/table/column-types'
+import {
+ collectColumnReferencedTableIds,
+ columnReferencedTableIds,
+ columnTypeOf,
+} from '@/lib/table/column-types'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { isEmptyCellValue } from '@/lib/table/deps'
import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter'
@@ -45,6 +49,7 @@ import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/
import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
import { LOCK_TOOLTIPS } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
import { useTimezoneState } from '@/hooks/queries/general-settings'
+import { useReferenceRowPreview } from '@/hooks/queries/table-reference-preview'
import {
useAddTableColumn,
useBatchCreateTableRows,
@@ -53,7 +58,6 @@ import {
useDeleteColumn,
useDeleteWorkflowGroup,
useFindTableRows,
- useReferenceRowPreview,
useTableNames,
useTableRunState,
useUpdateColumn,
@@ -457,6 +461,14 @@ async function chunkBatchUpdates(
)
}
+/** The referenced row's load state, as the preview renders it. */
+function resolveReferencePreviewState(query: ReturnType
) {
+ if (query.isError) return { status: 'error' } as const
+ if (query.isFetching || !query.data) return { status: 'loading' } as const
+ if (query.data.table === null) return { status: 'missing' } as const
+ return { status: 'ready', table: query.data.table, row: query.data.row } as const
+}
+
export function TableGrid({
workspaceId: propWorkspaceId,
tableId: propTableId,
@@ -625,23 +637,14 @@ export function TableGrid({
filter: effectiveFilter,
} = useTable({ workspaceId, tableId, queryOptions })
const referencedTableIds = useMemo(
- () =>
- referenceColumnsEnabled
- ? columns.flatMap((column) => {
- const referenceTableId = columnTypeOf(column).referencePreview?.getTableId(column)
- return referenceTableId ? [referenceTableId] : []
- })
- : [],
+ () => (referenceColumnsEnabled ? collectColumnReferencedTableIds(columns) : []),
[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])
+ const referenceTableNames = useMemo(
+ () => new Map((referencedTables ?? []).map((table) => [table.id, table.name])),
+ [referencedTables]
+ )
/** Sort is single-column, so only the first spec entry can be active. */
const activeSort = queryOptions.sort?.[0]
@@ -946,9 +949,9 @@ export function TableGrid({
)
const referencePreview = sourceColumn ? columnTypeOf(sourceColumn).referencePreview : undefined
if (!sourceRow || !sourceColumn || !referencePreview) return null
+ const [referenceTableId] = columnReferencedTableIds(sourceColumn)
return referencePreview.getRowId(sourceRow.data[expandedReference.sourceColumnKey]) ===
- expandedReference.referenceRowId &&
- referencePreview.getTableId(sourceColumn) === expandedReference.referenceTableId
+ expandedReference.referenceRowId && referenceTableId === expandedReference.referenceTableId
? expandedReference
: null
}, [displayColumns, rows, expandedReference, referenceColumnsEnabled])
@@ -966,17 +969,7 @@ export function TableGrid({
}
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 referencePreviewState = resolveReferencePreviewState(referencePreviewQuery)
const expandedSourceRowId = activeReferenceTarget?.sourceRowId ?? null
const rowVirtualizer = useVirtualizer({
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 1198a7d95f5..252ceb01d46 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts
@@ -12,7 +12,7 @@ import type {
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
-import { columnTypeOf } from '@/lib/table/column-types'
+import { columnReferencedTableIds } from '@/lib/table/column-types'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps'
import type {
@@ -177,7 +177,7 @@ function resolveReferenceTableName(
column: ColumnDefinition,
referenceTableNames: ReadonlyMap | undefined
): string | undefined {
- const tableId = columnTypeOf(column).referencePreview?.getTableId(column)
+ const [tableId] = columnReferencedTableIds(column)
return tableId ? referenceTableNames?.get(tableId) : undefined
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index 8aa35c36a2c..f07c0527899 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -620,21 +620,30 @@ function TablesContent() {
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 pendingDeleteTableIds = useMemo(() => {
+ if (isDeleteDialogOpen) return activeTable ? [activeTable.id] : []
+ if (isDeleteFolderDialogOpen && activeFolder) {
+ return tableIdsInFolderSubtrees(tables, [activeFolder.id], descendantFolderIds)
+ }
+ if (isBulkDeleteDialogOpen) {
+ return [
+ ...selectedTableIds,
+ ...tableIdsInFolderSubtrees(tables, selectedFolderIds, descendantFolderIds),
]
+ }
+ return []
+ }, [
+ isDeleteDialogOpen,
+ isDeleteFolderDialogOpen,
+ isBulkDeleteDialogOpen,
+ activeTable,
+ activeFolder,
+ tables,
+ descendantFolderIds,
+ selectedTableIds,
+ selectedFolderIds,
+ ])
const referencedByWarning = useReferencedByWarning(workspaceId, pendingDeleteTableIds)
const currentFolderActions: DropdownOption[] | undefined = useMemo(() => {
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 1a7b5530798..bd6acf72684 100644
--- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
+++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
@@ -58,7 +58,10 @@ import {
replaceKnowledgeDocumentSecretProvenanceInTx,
} from '@/lib/knowledge/secret-provenance'
import { getColumnId } from '@/lib/table/column-keys'
-import { collectColumnReferencedTableIds } from '@/lib/table/column-types/registry.server'
+import {
+ collectColumnReferencedTableIds,
+ columnReferencedTableIds,
+} from '@/lib/table/column-types/registry'
import { DEFAULT_TABLE_VIEW_NAME } from '@/lib/table/constants'
import { generateTableId } from '@/lib/table/ids'
import { keyBetween } from '@/lib/table/order-key'
@@ -402,6 +405,8 @@ function remapCopiedReferenceCells(
return remapped ?? data
}
+const TOO_MANY_FORK_TABLES_MESSAGE = `Cannot copy more than ${MAX_FORK_RESOURCE_IDS_PER_TYPE} tables including referenced dependencies`
+
/**
* 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
@@ -416,9 +421,7 @@ async function loadTableDefinitionsWithDependencies(
): 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`
- )
+ throw new Error(TOO_MANY_FORK_TABLES_MESSAGE)
}
const scheduledIds = new Set(orderedIds)
const definitionsById = new Map()
@@ -450,9 +453,7 @@ async function loadTableDefinitionsWithDependencies(
)
}
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`
- )
+ throw new Error(TOO_MANY_FORK_TABLES_MESSAGE)
}
scheduledIds.add(referencedId)
orderedIds.push(referencedId)
@@ -847,15 +848,16 @@ export async function copyForkResourceContainers(
updatedAt: now,
})
}
- const dependsOnChildIds = collectColumnReferencedTableIds(
- (definition.schema as TableSchema).columns
- ).flatMap((sourceId) => {
- const dependencyId = tableIdMap.get(sourceId)
- return dependencyId && dependencyId !== childTableId ? [dependencyId] : []
- })
+ const schemaColumns = (definition.schema as TableSchema).columns
+ const dependsOnChildIds = collectColumnReferencedTableIds(schemaColumns).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])
+ schemaColumns.flatMap((column) => {
+ const [sourceTargetId] = columnReferencedTableIds(column)
const childTargetId = sourceTargetId ? tableIdMap.get(sourceTargetId) : undefined
return childTargetId ? [[getColumnId(column), childTargetId]] : []
})
@@ -1462,19 +1464,24 @@ 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) {
+ const dependentsByDependency = new Map()
+ for (const table of contentPlan.tables) {
+ for (const dependencyId of table.dependsOnChildIds ?? []) {
+ const dependents = dependentsByDependency.get(dependencyId)
+ if (dependents) dependents.push(table)
+ else dependentsByDependency.set(dependencyId, [table])
+ }
+ }
+ const pendingFailedTableIds = [...failedTableIds]
+ while (pendingFailedTableIds.length > 0) {
+ const dependencyId = pendingFailedTableIds.pop() as string
+ for (const table of dependentsByDependency.get(dependencyId) ?? []) {
if (failedTableIds.has(table.childId)) continue
- if (!table.dependsOnChildIds?.some((dependencyId) => failedTableIds.has(dependencyId))) {
- continue
- }
failedTableIds.add(table.childId)
+ pendingFailedTableIds.push(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,
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 e0887f32d85..7665c964da3 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
@@ -66,8 +66,5 @@ 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
+ return { ...schema, columns: remapColumnReferencedTableIds(schema.columns, tableIdMap) }
}
diff --git a/apps/sim/hooks/queries/table-reference-preview.ts b/apps/sim/hooks/queries/table-reference-preview.ts
new file mode 100644
index 00000000000..47fea802d37
--- /dev/null
+++ b/apps/sim/hooks/queries/table-reference-preview.ts
@@ -0,0 +1,97 @@
+'use client'
+
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { isApiClientError } from '@/lib/api/client/errors'
+import { requestJson } from '@/lib/api/client/request'
+import { type GetTableRowResponse, getTableRowContract } from '@/lib/api/contracts/tables'
+import { collectColumnReferencedTableIds } from '@/lib/table/column-types'
+import { getTableDetailQueryOptions, getTableNamesQueryOptions } from '@/hooks/queries/tables'
+import { tableKeys } from '@/hooks/queries/utils/table-keys'
+
+/**
+ * Split out of `@/hooks/queries/tables` on purpose: this is the only table query that reads the
+ * column-type registry, and that barrel pulls all eleven type modules. The workspace sidebar
+ * imports `tables` for `useTablesList`, so leaving this here put the whole registry in the module
+ * graph of every route under `workspace/` — 13 modules that no sidebar render ever touches, and
+ * enough to trip the page-weight ratchet in `check-tool-registry-boundary`.
+ */
+
+export const TABLE_REFERENCE_PREVIEW_STALE_TIME = Number.POSITIVE_INFINITY
+const TABLE_REFERENCE_PREVIEW_GC_TIME = 0
+
+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
+ }
+}
+
+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 = collectColumnReferencedTableIds(table.schema.columns)
+ const referenceTables =
+ referenceTableIds.length === 0
+ ? []
+ : await queryClient.fetchQuery(
+ getTableNamesQueryOptions(workspaceId as string, referenceTableIds)
+ )
+ return { table, row, referenceTables }
+ },
+ enabled: Boolean(workspaceId && tableId && rowId && sourceRowId && sourceColumnKey),
+ /**
+ * `refetchOnMount: 'always'` is the load-bearing setting — every opening re-reads the
+ * referenced row — and `gcTime: 0` drops the entry once the preview closes. The infinite
+ * `staleTime` only keeps an open preview from refetching underneath the reader.
+ */
+ staleTime: TABLE_REFERENCE_PREVIEW_STALE_TIME,
+ gcTime: TABLE_REFERENCE_PREVIEW_GC_TIME,
+ refetchOnMount: 'always',
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ })
+}
diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts
index 6cb6e80aa7c..dfd6ea08198 100644
--- a/apps/sim/hooks/queries/tables.test.ts
+++ b/apps/sim/hooks/queries/tables.test.ts
@@ -67,6 +67,7 @@ import {
listTableNamesContract,
type TableViewWire,
} from '@/lib/api/contracts/tables'
+import { useReferenceRowPreview } from '@/hooks/queries/table-reference-preview'
import {
TABLE_DETAIL_STALE_TIME,
tableRowsInfiniteOptions,
@@ -75,7 +76,6 @@ import {
useDeleteColumn,
useDeleteTableRow,
useDeleteTableRows,
- useReferenceRowPreview,
useRestoreTable,
useTableNames,
useUpdateColumn,
@@ -226,10 +226,8 @@ describe('useReferenceRowPreview', () => {
}
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 } })
+ queryClient.fetchQuery.mockResolvedValueOnce(table).mockResolvedValueOnce(referenceTables)
+ vi.mocked(requestJson).mockResolvedValueOnce({ data: { row } })
useReferenceRowPreview({
workspaceId: WORKSPACE_ID,
@@ -244,10 +242,10 @@ describe('useReferenceRowPreview', () => {
row,
referenceTables,
})
- expect(requestJson).toHaveBeenNthCalledWith(2, listTableNamesContract, {
- body: { workspaceId: WORKSPACE_ID, tableIds: ['tbl-owners'] },
- signal,
- })
+ expect(queryClient.fetchQuery).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ queryKey: tableKeys.names(WORKSPACE_ID, ['tbl-owners']) })
+ )
})
it('does not fetch until every referenced-row identity is available', () => {
@@ -381,48 +379,29 @@ describe('useBatchUpdateTableRows', () => {
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)
+ expect(queryClient.invalidateQueries.mock.calls.map(([options]) => options?.queryKey)).toEqual([
+ tableKeys.referencePreviewsForRow(TABLE_ID, 'row-1'),
+ tableKeys.referencePreviewsForRow(TABLE_ID, 'row-2'),
+ ])
})
})
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))
+ const invalidatedKeys = queryClient.invalidateQueries.mock.calls.map(([options]) =>
+ JSON.stringify(options?.queryKey)
)
- 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(invalidatedKeys).toContain(
+ JSON.stringify(tableKeys.referencePreviewsForRow(TABLE_ID, rowId))
+ )
}
- expect(
- options?.predicate({
- queryKey: tableKeys.referencePreview(TABLE_ID, 'untouched-row', 'source-row', 'account'),
- })
- ).toBe(false)
+ expect(invalidatedKeys).not.toContain(
+ JSON.stringify(tableKeys.referencePreviewsForRow(TABLE_ID, 'untouched-row'))
+ )
+ expect(invalidatedKeys).not.toContain(
+ JSON.stringify(tableKeys.referencePreviewsForRow('other-table', rowIds[0]))
+ )
}
it('invalidates a referenced row after an update settles', () => {
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 0f761149bde..5015b8050f7 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -59,10 +59,8 @@ import {
deleteTableViewContract,
deleteWorkflowGroupContract,
findTableRowsContract,
- type GetTableRowResponse,
getEnrichmentDetailContract,
getTableContract,
- getTableRowContract,
type InsertTableRowBodyInput,
listActiveDispatchesContract,
listTableJobsContract,
@@ -111,7 +109,6 @@ 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,
@@ -148,8 +145,6 @@ 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 & {
@@ -245,25 +240,6 @@ 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) })
@@ -275,14 +251,9 @@ function invalidateReferencePreviews(
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)
- },
- })
+ for (const rowId of rowIds) {
+ queryClient.invalidateQueries({ queryKey: tableKeys.referencePreviewsForRow(tableId, rowId) })
+ }
}
function invalidateTableNames(queryClient: ReturnType) {
@@ -376,19 +347,28 @@ export function useTablesList(
})
}
+/**
+ * Shared id→name options so non-component callers — the row preview resolving its own
+ * Reference columns — read the same cache entry the grid fills instead of re-fetching.
+ */
+export function getTableNamesQueryOptions(workspaceId: string, tableIds: readonly string[]) {
+ const normalized = normalizeTableIds(tableIds)
+ return {
+ queryKey: tableKeys.names(workspaceId, normalized),
+ queryFn: ({ signal }: { signal?: AbortSignal }) =>
+ fetchTableNames(workspaceId, normalized, signal),
+ staleTime: TABLE_LIST_STALE_TIME,
+ }
+}
+
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)
- },
+ ...getTableNamesQueryOptions(workspaceId ?? '', tableIds),
enabled: Boolean(workspaceId && tableIds.length > 0),
- staleTime: TABLE_LIST_STALE_TIME,
})
}
@@ -405,60 +385,6 @@ 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.
diff --git a/apps/sim/hooks/queries/utils/table-keys.ts b/apps/sim/hooks/queries/utils/table-keys.ts
index e6ab9635dc7..47fdf78d185 100644
--- a/apps/sim/hooks/queries/utils/table-keys.ts
+++ b/apps/sim/hooks/queries/utils/table-keys.ts
@@ -31,8 +31,10 @@ export const tableKeys = {
referencePreviews: () => [...tableKeys.all, 'reference-preview'] as const,
referencePreviewsForTable: (tableId: string) =>
[...tableKeys.referencePreviews(), tableId] as const,
+ referencePreviewsForRow: (tableId: string, rowId: string) =>
+ [...tableKeys.referencePreviewsForTable(tableId), rowId] as const,
referencePreview: (tableId: string, rowId: string, sourceRowId = '', sourceColumnKey = '') =>
- [...tableKeys.referencePreviewsForTable(tableId), rowId, sourceRowId, sourceColumnKey] as const,
+ [...tableKeys.referencePreviewsForRow(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/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts
index 932efbd0daf..1c978b4c571 100644
--- a/apps/sim/lib/table/column-types/reference.ts
+++ b/apps/sim/lib/table/column-types/reference.ts
@@ -16,14 +16,14 @@ export const referenceColumnType: ColumnTypeDefinition = {
editor: 'text',
expandable: false,
referencePreview: {
- getTableId(column) {
- return column.referenceTableId
- },
getRowId(value) {
return typeof value === 'string' && value.length > 0 ? value : null
},
},
+ referencedTableIds: (column) =>
+ typeof column.referenceTableId === 'string' ? [column.referenceTableId] : [],
+
coerce: stringColumnType.coerce,
validateCell(value, column) {
diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts
index 3bee40cbf87..fa8cfc800c4 100644
--- a/apps/sim/lib/table/column-types/registry.server.ts
+++ b/apps/sim/lib/table/column-types/registry.server.ts
@@ -14,7 +14,11 @@
import { userTableDefinitions, userTableRows } from '@sim/db/schema'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import { OrchestrationError } from '@/lib/core/orchestration/types'
-import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types/registry'
+import {
+ COLUMN_TYPE_REGISTRY,
+ collectColumnReferencedTableIds,
+ columnReferencedTableIds,
+} from '@/lib/table/column-types/registry'
import type { ColumnType } from '@/lib/table/column-types/types'
import type {
ColumnCellMigration,
@@ -291,11 +295,9 @@ 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 [referenceTableId] = columnReferencedTableIds(column)
+ if (!referenceTableId) return column
const remappedTableId = tableIdMap.get(referenceTableId)
return remappedTableId && remappedTableId !== referenceTableId
? { ...column, referenceTableId: remappedTableId }
@@ -304,17 +306,6 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record 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[],
diff --git a/apps/sim/lib/table/column-types/registry.ts b/apps/sim/lib/table/column-types/registry.ts
index 8b4a89c1a24..873c7d12f4b 100644
--- a/apps/sim/lib/table/column-types/registry.ts
+++ b/apps/sim/lib/table/column-types/registry.ts
@@ -121,6 +121,16 @@ export function typeMetadataOf(column: ColumnDefinition): Partial | null {
return columnTypeOf(column).filterOperatorsFor?.(column) ?? null
diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts
index 6bc42ad2377..47f55590149 100644
--- a/apps/sim/lib/table/column-types/types.server.ts
+++ b/apps/sim/lib/table/column-types/types.server.ts
@@ -31,12 +31,6 @@ export interface ColumnCellMigrationContext {
export type ColumnCellMigration = (context: ColumnCellMigrationContext) => Promise
export interface ColumnTypeServerDefinition {
- /**
- * Table IDs named by this column's type-specific metadata. The server
- * registry uses this to validate cross-table references in one batch before
- * a schema is persisted. Omitted by types that do not reference tables.
- */
- readonly referencedTableIds?: (column: ColumnDefinition) => readonly string[]
/**
* Rewrites this column's table references through a source-to-target identity map.
* Omitted by types that do not reference tables.
diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts
index a9f9ce81375..a5c1c5a0858 100644
--- a/apps/sim/lib/table/column-types/types.ts
+++ b/apps/sim/lib/table/column-types/types.ts
@@ -83,7 +83,6 @@ export interface TimestampValidation {
}
export interface ColumnReferencePreviewDefinition {
- getTableId(column: ColumnDefinition): string | undefined
getRowId(value: unknown): string | null
}
@@ -155,8 +154,15 @@ export interface ColumnTypeDefinition {
* bounded, structured value.
*/
readonly expandable: boolean
- /** Optional inline referenced-row presentation owned by this column type. */
+ /** Reads the referenced row id out of a stored cell, for types that hold one. */
readonly referencePreview?: ColumnReferencePreviewDefinition
+ /**
+ * Table IDs named by this column's type-specific metadata. One accessor for
+ * every consumer — schema validation, fork remapping, delete warnings, and
+ * the grid all read table references through it. Omitted by types that do
+ * not reference tables.
+ */
+ readonly referencedTableIds?: (column: ColumnDefinition) => readonly string[]
/** `inputMode` for the text editor, when the type wants a specific keypad. */
readonly inputMode?: 'decimal'
/**
diff --git a/apps/sim/lib/table/reference-columns/referrers.ts b/apps/sim/lib/table/reference-columns/referrers.ts
index a38005b2b7f..90b8c768088 100644
--- a/apps/sim/lib/table/reference-columns/referrers.ts
+++ b/apps/sim/lib/table/reference-columns/referrers.ts
@@ -1,6 +1,8 @@
-import { columnTypeOf } from '@/lib/table/column-types'
+import { collectColumnReferencedTableIds } from '@/lib/table/column-types'
import type { TableDefinition } from '@/lib/table/types'
+const REFERRER_NAME_COLLATOR = new Intl.Collator('en')
+
type ReferenceScanTable = Pick
/**
@@ -16,11 +18,10 @@ export function findReferencingTables(
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)
- })
+ const referencesDeletedTable = collectColumnReferencedTableIds(table.schema.columns).some(
+ (referenceTableId) => deletedTableIds.has(referenceTableId)
+ )
if (referencesDeletedTable) referencing.push({ id: table.id, name: table.name })
}
- return referencing.sort((left, right) => left.name.localeCompare(right.name))
+ return referencing.sort((left, right) => REFERRER_NAME_COLLATOR.compare(left.name, right.name))
}
From aeeda42b0ee20d416f5ed431f322b1582aa3b5f5 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 15 Sep 2026 19:44:46 -0700
Subject: [PATCH 10/10] chore(audits): record the module growth the reference
preview adds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Chat and Home render the table grid inline, so the preview component, its
query module, the referenced-by warning hook and the referrers lookup land in
their graphs too — five modules the feature genuinely ships, not a barrel
dragging code in. Only the three entries this branch actually grows are
re-recorded; the rest keep their existing numbers.
Co-Authored-By: Claude Opus 5
---
scripts/check-tool-registry-boundary.baseline.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json
index 93b812d2985..34bbdf10f87 100644
--- a/scripts/check-tool-registry-boundary.baseline.json
+++ b/scripts/check-tool-registry-boundary.baseline.json
@@ -95,7 +95,7 @@
"gateways": {}
},
"app/workspace/[workspaceId]/chat/[chatId]/page.tsx": {
- "modules": 3068,
+ "modules": 3133,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1498,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 910,
@@ -185,7 +185,7 @@
"gateways": {}
},
"app/workspace/[workspaceId]/home/page.tsx": {
- "modules": 3068,
+ "modules": 3133,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1498,
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 910,
@@ -574,7 +574,7 @@
}
},
"app/workspace/[workspaceId]/tables/[tableId]/page.tsx": {
- "modules": 1836,
+ "modules": 1878,
"gateways": {
"apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1678,
"apps/sim/triggers/registry.ts": 522,