-
+
{table?.name ?? 'Referenced table'}
From 9b1191e71adb20cf306b019728c0f8a570f0320f Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:20:53 -0700
Subject: [PATCH 03/12] revert(tables): keep preview dividers within grid
---
.../components/table-grid/reference-row-preview.test.tsx | 2 --
.../[tableId]/components/table-grid/reference-row-preview.tsx | 2 +-
2 files changed, 1 insertion(+), 3 deletions(-)
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
index 6e392091e74..758fac2b367 100644
--- 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
@@ -112,8 +112,6 @@ describe('ReferenceRowPreview', () => {
expect(container.querySelector('td > div')?.className).toContain(
`h-[${REFERENCE_ROW_PREVIEW_HEIGHT}px]`
)
- expect(container.querySelector('td > div > div')?.className).toContain('border-t')
- expect(container.querySelector('td > div > div')?.className).toContain('border-b')
const subtable = container.querySelector('td table')
expect(subtable?.className).toContain('w-[100cqw]')
expect(subtable?.className).toContain('border-t')
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
index ae3dbb530f3..5a84cae3ec6 100644
--- 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
@@ -122,7 +122,7 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
-
+
{table?.name ?? 'Referenced table'}
From a9482dafab44699e9db13e4aad9dd618427f5f70 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:28:19 -0700
Subject: [PATCH 04/12] fix(tables): contain reference preview within table
---
.../components/table-grid/reference-row-preview.test.tsx | 3 +++
.../components/table-grid/reference-row-preview.tsx | 5 ++++-
2 files changed, 7 insertions(+), 1 deletion(-)
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
index 758fac2b367..6674d8ee59e 100644
--- 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
@@ -107,6 +107,9 @@ describe('ReferenceRowPreview', () => {
expect(container.textContent).toContain('Acme')
expect(container.textContent).toContain('Enterprise')
expect(container.textContent).not.toContain('Open in sub view')
+ 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(
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
index 5a84cae3ec6..b8865049331 100644
--- 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
@@ -120,7 +120,10 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
return (
- |
+ |
From e4d355c9609753e9e97ada74bf55babe9fdfda2a Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:37:53 -0700
Subject: [PATCH 05/12] improvement(tables): finish reference preview layout
---
.../table-grid/reference-row-preview.test.tsx | 7 ++++++-
.../table-grid/reference-row-preview.tsx | 19 +++++++++++++++----
2 files changed, 21 insertions(+), 5 deletions(-)
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
index 6674d8ee59e..f4b89af010d 100644
--- 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
@@ -107,6 +107,11 @@ describe('ReferenceRowPreview', () => {
expect(container.textContent).toContain('Acme')
expect(container.textContent).toContain('Enterprise')
expect(container.textContent).not.toContain('Open in sub view')
+ const goToTableLink = Array.from(container.querySelectorAll('a')).find(
+ (link) => link.textContent === 'Go to table'
+ )
+ expect(goToTableLink?.getAttribute('href')).toBe('/workspace/workspace-1/tables/table-accounts')
+ expect(goToTableLink?.parentElement?.className).toContain('h-9')
const previewCell = container.querySelector('tbody > tr > td')
expect(previewCell?.className).toContain('overflow-clip')
expect(previewCell?.className).toContain('border-r')
@@ -120,7 +125,7 @@ describe('ReferenceRowPreview', () => {
expect(subtable?.className).toContain('border-t')
expect(subtable?.className).toContain('border-b')
expect(subtable?.querySelectorAll('col')).toHaveLength(3)
- expect(container.querySelector('td > div > div > div:last-child')?.className).toContain(
+ expect(container.querySelector('.overscroll-x-contain')?.className).toContain(
'overscroll-x-contain'
)
expect(container.innerHTML).not.toContain('rounded-md')
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
index b8865049331..2ad4e6c9a78 100644
--- 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
@@ -1,8 +1,10 @@
'use client'
import { memo, type ReactNode, useMemo } from 'react'
+import { buttonVariants } from '@sim/emcn'
import { Loader } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
+import Link from 'next/link'
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'
@@ -10,12 +12,12 @@ import { expandToDisplayColumns } from '@/app/workspace/[workspaceId]/tables/[ta
import { useTable, useTableRow } from '@/hooks/queries/tables'
/**
- * Must match the sticky anchor's `h-[184px]` class below because the row
+ * 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, while its `100cqw` child uses TableGrid's
* inline-size query container to cover the visible viewport.
*/
-export const REFERENCE_ROW_PREVIEW_HEIGHT = 184
+export const REFERENCE_ROW_PREVIEW_HEIGHT = 144
const ReferenceIcon = columnTypeById('reference').icon
@@ -124,16 +126,25 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
colSpan={colSpan}
className='overflow-clip border-[var(--border)] border-r border-b bg-[var(--surface-2)] p-0'
>
-
+
{table?.name ?? 'Referenced table'}
-
+
{content}
+
+
+
+ Go to table
+
+
|
From 80afd8d51e16848ad3c6dafdd110aa007aa151d8 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 26 Aug 2026 11:30:18 -0700
Subject: [PATCH 06/12] fix(tables): stabilize reference preview scrolling
---
.../table-grid/reference-row-preview.test.tsx | 174 ++++++++++++++++--
.../table-grid/reference-row-preview.tsx | 140 +++++++++-----
2 files changed, 245 insertions(+), 69 deletions(-)
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
index f4b89af010d..4f60ab55cdd 100644
--- 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
@@ -78,25 +78,43 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount())
container.remove()
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
})
function renderPreview() {
+ const preview = (
+
+ )
+
act(() => {
- root.render(
-
- )
+ root.render({preview} )
})
}
+function horizontalRect(left: number, right: number): DOMRect {
+ return {
+ bottom: 0,
+ height: 0,
+ left,
+ right,
+ top: 0,
+ width: right - left,
+ x: left,
+ y: 0,
+ toJSON: () => ({}),
+ }
+}
+
describe('ReferenceRowPreview', () => {
it('shows the referenced table schema and the matching row inline', () => {
renderPreview()
@@ -120,17 +138,133 @@ describe('ReferenceRowPreview', () => {
expect(container.querySelector('td > div')?.className).toContain(
`h-[${REFERENCE_ROW_PREVIEW_HEIGHT}px]`
)
- const subtable = container.querySelector('td table')
- expect(subtable?.className).toContain('w-[100cqw]')
- expect(subtable?.className).toContain('border-t')
- expect(subtable?.className).toContain('border-b')
- expect(subtable?.querySelectorAll('col')).toHaveLength(3)
- expect(container.querySelector('.overscroll-x-contain')?.className).toContain(
- 'overscroll-x-contain'
- )
+ const subtable = container.querySelector('[role="table"]')
+ expect(subtable?.className).toContain('w-full')
+ expect(subtable?.className).toContain('h-full')
+ 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 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('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')
+ 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')
+ const scrollListener = registeredListeners.find(
+ ({ target, type }) => target === scrollRoot && type === 'scroll'
+ )?.listener
+ if (!scrollListener) throw new Error('Expected the scroll 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,
+ })
+ })
+
it('shows no match when the stored row ID does not resolve', () => {
rowQuery.data = null
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
index 2ad4e6c9a78..1413e8b3d2a 100644
--- 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
@@ -1,6 +1,6 @@
'use client'
-import { memo, type ReactNode, useMemo } from 'react'
+import { memo, type ReactNode, useLayoutEffect, useMemo, useRef } from 'react'
import { buttonVariants } from '@sim/emcn'
import { Loader } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
@@ -14,8 +14,7 @@ import { useTable, useTableRow } from '@/hooks/queries/tables'
/**
* Must match the sticky anchor's `h-[144px]` class below because the row
* virtualizer reserves this exact height. The zero-width anchor stays sticky
- * across the full table width, while its `100cqw` child uses TableGrid's
- * inline-size query container to cover the visible viewport.
+ * across the full table width without JavaScript-driven positioning.
*/
export const REFERENCE_ROW_PREVIEW_HEIGHT = 144
@@ -34,6 +33,8 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
referenceRowId,
colSpan,
}: ReferenceRowPreviewProps) {
+ const previewCellRef = useRef(null)
+ const previewShellRef = useRef(null)
const tableQuery = useTable(workspaceId, referenceTableId)
const rowQuery = useTableRow(workspaceId, referenceTableId, referenceRowId)
const table = tableQuery.data
@@ -43,6 +44,48 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
[table?.schema.columns]
)
+ 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()
+ }
+ }, [])
+
let content: ReactNode
if (tableQuery.isLoading || rowQuery.isLoading) {
content = (
@@ -71,69 +114,68 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
)
} else {
content = (
-
-
+
+
+ {columns.map((column) => (
+
+
+
+ {column.name}
+
+
+ ))}
+
+
+
{columns.map((column) => (
-
+
))}
-
-
-
-
- {columns.map((column) => (
- |
-
-
- {column.name}
-
- |
- ))}
- |
-
-
-
-
- {columns.map((column) => (
- |
-
-
-
- |
- ))}
- |
-
-
-
+
+
+
)
}
return (
-
+
{table?.name ?? 'Referenced table'}
-
+
{content}
From 81b88470562a48dbd12d2171b3310d58d29b6bf8 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 26 Aug 2026 12:10:38 -0700
Subject: [PATCH 07/12] fix(tables): preserve reference preview scrolling and
clipping
---
.../table-grid/reference-row-preview.test.tsx | 11 +++++++++++
.../components/table-grid/reference-row-preview.tsx | 7 +++++--
2 files changed, 16 insertions(+), 2 deletions(-)
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
index 4f60ab55cdd..c80d70a0bd4 100644
--- 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
@@ -141,10 +141,21 @@ describe('ReferenceRowPreview', () => {
const subtable = container.querySelector('[role="table"]')
expect(subtable?.className).toContain('w-full')
expect(subtable?.className).toContain('h-full')
+ expect(subtable?.className).toContain('cursor-default')
+ expect(subtable?.className).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')
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
index 1413e8b3d2a..e82bf3f0b49 100644
--- 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
@@ -114,7 +114,10 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
)
} else {
content = (
-
+
{columns.map((column) => (
-
+
Date: Wed, 26 Aug 2026 12:51:16 -0700
Subject: [PATCH 08/12] fix(tables): route reference preview wheel scrolling
---
.../table-grid/reference-row-preview.test.tsx | 50 ++++++++++++++++++-
.../table-grid/reference-row-preview.tsx | 25 ++++++++--
2 files changed, 68 insertions(+), 7 deletions(-)
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
index c80d70a0bd4..5a8fd74990e 100644
--- 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
@@ -141,8 +141,8 @@ describe('ReferenceRowPreview', () => {
const subtable = container.querySelector('[role="table"]')
expect(subtable?.className).toContain('w-full')
expect(subtable?.className).toContain('h-full')
- expect(subtable?.className).toContain('cursor-default')
- expect(subtable?.className).toContain('select-none')
+ 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)
@@ -163,6 +163,41 @@ describe('ReferenceRowPreview', () => {
expect(container.innerHTML).not.toContain('rounded-md')
})
+ 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 () {
@@ -248,12 +283,18 @@ describe('ReferenceRowPreview', () => {
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)
@@ -274,6 +315,11 @@ describe('ReferenceRowPreview', () => {
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', () => {
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
index e82bf3f0b49..6db84cfa9bf 100644
--- 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
@@ -35,6 +35,7 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
}: ReferenceRowPreviewProps) {
const previewCellRef = useRef(null)
const previewShellRef = useRef(null)
+ const previewViewportRef = useRef(null)
const tableQuery = useTable(workspaceId, referenceTableId)
const rowQuery = useTableRow(workspaceId, referenceTableId, referenceRowId)
const table = tableQuery.data
@@ -86,6 +87,20 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
}
}, [])
+ useLayoutEffect(() => {
+ const previewViewport = previewViewportRef.current
+ if (!previewViewport) return
+
+ const handleWheel = (event: WheelEvent) => {
+ if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return
+ event.preventDefault()
+ previewViewport.scrollLeft += event.deltaX
+ }
+
+ previewViewport.addEventListener('wheel', handleWheel, { passive: false })
+ return () => previewViewport.removeEventListener('wheel', handleWheel)
+ }, [])
+
let content: ReactNode
if (tableQuery.isLoading || rowQuery.isLoading) {
content = (
@@ -114,10 +129,7 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
)
} else {
content = (
-
+
{columns.map((column) => (
{table?.name ?? 'Referenced table'}
-
+
{content}
From fabb273adf9115c17220680828bf7989b3855d14 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:18:27 -0700
Subject: [PATCH 09/12] improvement(tables): move preview navigation into
header
---
.../table-grid/reference-row-preview.test.tsx | 14 +++++++++++---
.../table-grid/reference-row-preview.tsx | 19 ++++++++++---------
2 files changed, 21 insertions(+), 12 deletions(-)
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
index 5a8fd74990e..8d74a5aa15e 100644
--- 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
@@ -29,6 +29,7 @@ vi.mock('@/lib/table/column-types', () => ({
}))
vi.mock('@sim/emcn/icons', () => ({
+ ArrowRight: () => null,
Loader: () => null,
}))
@@ -125,11 +126,18 @@ describe('ReferenceRowPreview', () => {
expect(container.textContent).toContain('Acme')
expect(container.textContent).toContain('Enterprise')
expect(container.textContent).not.toContain('Open in sub view')
- const goToTableLink = Array.from(container.querySelectorAll('a')).find(
- (link) => link.textContent === 'Go to table'
- )
+ const goToTableLink = container.querySelector('a[aria-label="Go to table"]')
expect(goToTableLink?.getAttribute('href')).toBe('/workspace/workspace-1/tables/table-accounts')
+ expect(goToTableLink?.getAttribute('title')).toBe('Go to table')
+ expect(goToTableLink?.className).toContain('size-[20px]')
+ expect(goToTableLink?.className).toContain('hover-hover:bg-[var(--surface-active)]')
expect(goToTableLink?.parentElement?.className).toContain('h-9')
+ expect(goToTableLink?.parentElement?.className).toContain('gap-1.5')
+ expect(goToTableLink?.previousElementSibling?.textContent).toBe('Accounts')
+ expect(goToTableLink?.textContent).toBe('')
+ 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')
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
index 6db84cfa9bf..4921fb3e00e 100644
--- 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
@@ -2,7 +2,7 @@
import { memo, type ReactNode, useLayoutEffect, useMemo, useRef } from 'react'
import { buttonVariants } from '@sim/emcn'
-import { Loader } from '@sim/emcn/icons'
+import { ArrowRight, Loader } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import Link from 'next/link'
import { columnTypeById } from '@/lib/table/column-types'
@@ -188,6 +188,14 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
{table?.name ?? 'Referenced table'}
+
+
+
|
From b734463a023c32324272a1686114465799ff1354 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Fri, 28 Aug 2026 20:16:24 -0700
Subject: [PATCH 10/12] improvement(tables): polish reference row previews
---
.../table-grid/cells/cell-content.test.tsx | 70 ++++++++++
.../table-grid/cells/cell-content.tsx | 2 +-
.../table-grid/cells/cell-render.test.tsx | 74 +++++++++-
.../table-grid/cells/cell-render.tsx | 26 ++--
.../table-grid/reference-row-preview.test.tsx | 128 ++++++++++++-----
.../table-grid/reference-row-preview.tsx | 124 +++++++++++------
.../components/table-grid/table-grid.tsx | 100 ++++++++++++-
.../[tableId]/components/table-grid/types.ts | 2 +
.../components/table-grid/utils.test.ts | 125 +++++++++++++++++
.../[tableId]/components/table-grid/utils.ts | 42 +++++-
apps/sim/hooks/queries/tables.test.ts | 131 +++++++++++++++++-
apps/sim/hooks/queries/tables.ts | 73 ++++++++++
apps/sim/hooks/queries/utils/table-keys.ts | 3 +
apps/sim/lib/table/column-types/reference.ts | 3 -
apps/sim/lib/table/column-types/types.ts | 1 -
15 files changed, 795 insertions(+), 109 deletions(-)
create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx
new file mode 100644
index 00000000000..67d97748a1f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx
@@ -0,0 +1,70 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createTableColumn } from '@sim/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render',
+ () => ({
+ resolveCellRender: () => ({ kind: 'empty' }),
+ CellRender: () => null,
+ })
+)
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors',
+ () => ({ InlineEditor: () => })
+)
+
+import { CellContent } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content'
+
+const COLUMN: DisplayColumn = {
+ ...createTableColumn({ id: 'col-name', name: 'Name', type: 'string' }),
+ key: 'col-name',
+ groupSize: 1,
+ groupStartColIndex: 0,
+ headerLabel: 'Name',
+ isGroupStart: true,
+}
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ act(() => {
+ root = createRoot(container)
+ })
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('CellContent', () => {
+ it('keeps the inline editor below the sticky table header', () => {
+ act(() => {
+ root.render(
+
+ )
+ })
+
+ const editorLayer = container.querySelector('[data-testid="inline-editor"]')?.parentElement
+ expect(editorLayer?.className).toContain('z-[9]')
+ expect(editorLayer?.className).not.toContain('z-10')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx
index c628cc9a390..9eb068b162b 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
@@ -64,7 +64,7 @@ export function CellContent({
return (
<>
{isEditing && (
-
+
({
Badge: ({ children }: { children: React.ReactNode }) => {children},
+ Button: ({
+ children,
+ size,
+ variant,
+ ...props
+ }: React.ButtonHTMLAttributes & {
+ size?: string
+ variant?: string
+ }) => (
+
+ ),
Checkbox: () => null,
- Chip: ({ children, ...props }: React.ButtonHTMLAttributes) => (
-
+ ChipTag: ({
+ children,
+ variant,
+ ...props
+ }: React.HTMLAttributes & { variant?: string }) => (
+
+ {children}
+
),
cn: (...values: Array) => values.filter(Boolean).join(' '),
Tooltip: {
@@ -45,6 +64,7 @@ const REFERENCE_COLUMN: DisplayColumn = {
name: 'Account',
type: 'reference',
referenceTableId: 'table-accounts',
+ referenceTableName: 'Accounts',
groupSize: 1,
groupStartColIndex: 0,
headerLabel: 'Account',
@@ -69,7 +89,7 @@ afterEach(() => {
})
describe('reference cell rendering', () => {
- it('resolves a stored row ID to a chip labeled with the reference column name', () => {
+ it('resolves a stored row ID to a chip labeled with the referenced table name', () => {
expect(
resolveCellRender({
value: 'row-account-1',
@@ -77,7 +97,7 @@ describe('reference cell rendering', () => {
column: REFERENCE_COLUMN,
waitingOnLabels: undefined,
})
- ).toMatchObject({ kind: 'column-chip', label: 'Account' })
+ ).toEqual({ kind: 'column-chip', label: 'Accounts' })
})
it('keeps an empty reference cell empty', () => {
@@ -91,6 +111,17 @@ describe('reference cell rendering', () => {
).toEqual({ kind: 'empty' })
})
+ it('uses a neutral label while the referenced table name is unavailable', () => {
+ expect(
+ resolveCellRender({
+ value: 'row-account-1',
+ exec: undefined,
+ column: { ...REFERENCE_COLUMN, referenceTableName: undefined },
+ waitingOnLabels: undefined,
+ })
+ ).toEqual({ kind: 'column-chip', label: 'Referenced table' })
+ })
+
it('opens the referenced row from the chip without exposing its stored row ID', () => {
const onReferenceClick = vi.fn()
@@ -110,11 +141,44 @@ describe('reference cell rendering', () => {
})
const chip = container.querySelector('button')
- expect(chip?.textContent).toBe('Account')
+ expect(chip?.textContent).toBe('Accounts')
+ expect(chip?.dataset.variant).toBe('ghost')
+ expect(chip?.dataset.size).toBe('sm')
+ expect(chip?.className).toContain('max-w-full')
+ expect(chip?.className).toContain('p-0')
+ expect(chip?.querySelector('svg')).toBeNull()
+ const tag = chip?.querySelector('[data-chip-tag-variant="field"]')
+ expect(tag?.textContent).toBe('Accounts')
+ expect(tag?.className).toContain('min-w-0')
+ expect(tag?.className).toContain('max-w-full')
act(() => chip?.click())
expect(onReferenceClick).toHaveBeenCalledOnce()
expect(container.textContent).not.toContain('row-account-1')
})
+
+ it('keeps a chip double-click from reaching the reference cell', () => {
+ const onCellDoubleClick = vi.fn()
+
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+
+ act(() => {
+ container
+ .querySelector('button')
+ ?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
+ })
+
+ expect(onCellDoubleClick).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx
index d8f2a8da750..c7d217cf6c2 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, Chip, 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'
@@ -28,7 +28,7 @@ export type CellRenderKind =
// Plain typed cells
| { kind: 'boolean'; checked: boolean }
| { kind: 'select'; options: SelectOption[] }
- | { kind: 'column-chip'; label: string; icon: React.ComponentType<{ className?: string }> }
+ | { kind: 'column-chip'; label: string }
| { kind: 'json'; text: string }
| { kind: 'date'; text: string }
| { kind: 'url'; text: string; href: string; domain: string }
@@ -135,8 +135,7 @@ export function resolveCellRender({
return rowId
? {
kind: 'column-chip',
- label: typeDefinition.referencePreview.getChipLabel(column),
- icon: typeDefinition.icon,
+ label: column.referenceTableName ?? 'Referenced table',
}
: { kind: 'empty' }
}
@@ -397,24 +396,25 @@ export function CellRender({
)
- case 'column-chip': {
- const ChipIcon = kind.icon
+ case 'column-chip':
return (
- {
event.stopPropagation()
referenceAction?.onClick()
}}
+ onDoubleClick={(event) => event.stopPropagation()}
>
- {kind.label}
-
+
+ {kind.label}
+
+
)
- }
case 'json':
return (
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
index 8d74a5aa15e..632c1111366 100644
--- 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
@@ -6,35 +6,26 @@ import { createTableColumn, createTableDefinition, createTableRow } from '@sim/t
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { tableQuery, rowQuery } = vi.hoisted(() => ({
- tableQuery: {
- data: undefined as ReturnType | undefined,
- isLoading: false,
- isError: false,
- },
- rowQuery: {
+const { previewQuery } = vi.hoisted(() => ({
+ previewQuery: {
data: undefined as ReturnType | null | undefined,
- isLoading: false,
isError: false,
},
}))
-vi.mock('@/hooks/queries/tables', () => ({
- useTable: () => tableQuery,
- useTableRow: () => rowQuery,
-}))
-
vi.mock('@/lib/table/column-types', () => ({
columnTypeById: () => ({ icon: () => null }),
}))
vi.mock('@sim/emcn/icons', () => ({
- ArrowRight: () => null,
Loader: () => null,
+ SquareArrowUpRight: () => ,
}))
vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells', () => ({
- CellContent: ({ value }: { value: unknown }) => {String(value)},
+ CellContent: ({ column, value }: { column: { referenceTableName?: string }; value: unknown }) => (
+ {String(value)}
+ ),
}))
vi.mock(
@@ -49,6 +40,12 @@ import {
let container: HTMLDivElement
let root: Root
+let previewTable: ReturnType | undefined
+let previewTableStatus: 'error' | 'ready'
+const REFERENCE_TABLE_NAMES = new Map([
+ ['table-accounts', 'Accounts'],
+ ['table-owners', 'Owners'],
+])
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
@@ -56,19 +53,17 @@ beforeEach(() => {
createTableColumn({ id: 'col-name', name: 'Name', type: 'string' }),
createTableColumn({ id: 'col-tier', name: 'Tier', type: 'string' }),
]
- tableQuery.data = createTableDefinition({
+ previewTable = createTableDefinition({
id: 'table-accounts',
name: 'Accounts',
columns,
})
- tableQuery.isLoading = false
- tableQuery.isError = false
- rowQuery.data = createTableRow({
+ previewQuery.data = createTableRow({
id: 'row-account-1',
data: { 'col-name': 'Acme', 'col-tier': 'Enterprise' },
})
- rowQuery.isLoading = false
- rowQuery.isError = false
+ previewQuery.isError = false
+ previewTableStatus = 'ready'
container = document.createElement('div')
document.body.appendChild(container)
act(() => {
@@ -90,8 +85,12 @@ function renderPreview() {
@@ -134,7 +133,11 @@ describe('ReferenceRowPreview', () => {
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()
@@ -171,6 +174,31 @@ describe('ReferenceRowPreview', () => {
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()
@@ -331,23 +359,15 @@ describe('ReferenceRowPreview', () => {
})
it('shows no match when the stored row ID does not resolve', () => {
- rowQuery.data = null
+ previewQuery.data = null
renderPreview()
expect(container.textContent).toContain('No matching row')
})
- it('shows a loading state while either referenced resource is loading', () => {
- rowQuery.isLoading = true
-
- renderPreview()
-
- expect(container.textContent).toContain('Loading referenced row')
- })
-
it('keeps non-404 failures distinct from missing rows', () => {
- rowQuery.isError = true
+ previewQuery.isError = true
renderPreview()
@@ -356,11 +376,51 @@ describe('ReferenceRowPreview', () => {
})
it('shows an empty-schema state when the referenced table has no columns', () => {
- if (!tableQuery.data) throw new Error('Expected the table fixture to be initialized')
- tableQuery.data.schema.columns = []
+ if (!previewTable) throw new Error('Expected the referenced table fixture')
+ previewTable.schema.columns = []
renderPreview()
expect(container.textContent).toContain('This table has no columns')
})
+
+ it('shows a terminal error when table metadata fails to load', () => {
+ previewTable = undefined
+ previewTableStatus = 'error'
+
+ renderPreview()
+
+ expect(container.textContent).toContain("Couldn't load referenced table")
+ expect(container.textContent).not.toContain('Loading referenced table')
+ })
+
+ it('shows a terminal unavailable state when prefetched metadata has no table', () => {
+ previewTable = undefined
+ previewTableStatus = 'ready'
+
+ renderPreview()
+
+ expect(container.textContent).toContain('Referenced table unavailable')
+ expect(container.textContent).not.toContain('Loading referenced table')
+ })
+
+ it('preserves row errors for an empty schema', () => {
+ if (!previewTable) throw new Error('Expected the referenced table fixture')
+ previewTable.schema.columns = []
+ previewQuery.data = undefined
+ previewQuery.isError = true
+
+ renderPreview()
+ expect(container.textContent).toContain("Couldn't load referenced row")
+ })
+
+ it('preserves a missing row for an empty schema', () => {
+ if (!previewTable) throw new Error('Expected the referenced table fixture')
+ previewTable.schema.columns = []
+ previewQuery.data = null
+
+ renderPreview()
+ expect(container.textContent).toContain('No matching row')
+ expect(container.textContent).not.toContain('This table has no columns')
+ })
})
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx
index 4921fb3e00e..a12cccf12c8 100644
--- 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
@@ -2,14 +2,18 @@
import { memo, type ReactNode, useLayoutEffect, useMemo, useRef } from 'react'
import { buttonVariants } from '@sim/emcn'
-import { ArrowRight, Loader } from '@sim/emcn/icons'
+import { SquareArrowUpRight } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import Link from 'next/link'
+import type { TableDefinition } from '@/lib/table'
import { columnTypeById } from '@/lib/table/column-types'
import { CellContent } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells'
import { ColumnTypeIcon } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon'
-import { expandToDisplayColumns } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils'
-import { useTable, useTableRow } from '@/hooks/queries/tables'
+import {
+ expandToDisplayColumns,
+ type ReferenceTableLoadStatus,
+} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils'
+import type { useReferenceRowPreview } from '@/hooks/queries/tables'
/**
* Must match the sticky anchor's `h-[144px]` class below because the row
@@ -23,26 +27,30 @@ const ReferenceIcon = columnTypeById('reference').icon
interface ReferenceRowPreviewProps {
workspaceId: string
referenceTableId: string
- referenceRowId: string
+ table: TableDefinition | undefined
+ tableStatus: Exclude
+ referenceTableNames: ReadonlyMap
colSpan: number
+ row: NonNullable['data']>['row'] | undefined
+ rowError: boolean
}
export const ReferenceRowPreview = memo(function ReferenceRowPreview({
workspaceId,
referenceTableId,
- referenceRowId,
+ table,
+ tableStatus,
+ referenceTableNames,
colSpan,
+ row,
+ rowError,
}: ReferenceRowPreviewProps) {
const previewCellRef = useRef(null)
const previewShellRef = useRef(null)
const previewViewportRef = useRef(null)
- const tableQuery = useTable(workspaceId, referenceTableId)
- const rowQuery = useTableRow(workspaceId, referenceTableId, referenceRowId)
- const table = tableQuery.data
- const row = rowQuery.data
const columns = useMemo(
- () => expandToDisplayColumns(table?.schema.columns ?? [], []),
- [table?.schema.columns]
+ () => expandToDisplayColumns(table?.schema.columns ?? [], [], referenceTableNames),
+ [table?.schema.columns, referenceTableNames]
)
useLayoutEffect(() => {
@@ -102,20 +110,25 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
}, [])
let content: ReactNode
- if (tableQuery.isLoading || rowQuery.isLoading) {
+ if (tableStatus === 'error') {
content = (
-
-
- Loading referenced row
+
+ Couldn't load referenced table
+
+ )
+ } else if (!table) {
+ content = (
+
+ Referenced table unavailable
)
- } else if (tableQuery.isError || rowQuery.isError) {
+ } else if (columns.length === 0 && rowError) {
content = (
Couldn't load referenced row
)
- } else if (!row) {
+ } else if (columns.length === 0 && !row) {
content = (
No matching row
@@ -149,25 +162,43 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
/>
- {columns.map((column) => (
+ {rowError ? (
-
-
-
+ Couldn't load referenced row
- ))}
-
+ ) : !row ? (
+
+ No matching row
+
+ ) : (
+ <>
+ {columns.map((column) => (
+
+ ))}
+
+ >
+ )}
)
@@ -186,16 +217,25 @@ export const ReferenceRowPreview = memo(function ReferenceRowPreview({
className='flex h-full w-[var(--reference-preview-width,100cqw)] min-w-0 flex-col bg-[var(--surface-2)]'
>
-
- {table?.name ?? 'Referenced table'}
-
-
-
+ {table ? (
+ <>
+
+ {table.name}
+
+
+
+ >
+ ) : (
+ <>
+
+ Table unavailable
+ >
+ )}
collectReferenceTableIds(columns), [columns])
+ const referenceTableQueries = useReferenceTableMetadata(workspaceId, referenceTableIds)
+ const directReferenceTables = useMemo(
+ () => referenceTableQueries.flatMap(({ data }) => (data ? [data] : [])),
+ [referenceTableQueries]
+ )
+ const nestedReferenceTableIds = useMemo(() => {
+ const directIds = new Set(referenceTableIds)
+ return collectReferenceTableIds(
+ directReferenceTables.flatMap((table) => table.schema.columns)
+ ).filter((id) => !directIds.has(id))
+ }, [directReferenceTables, referenceTableIds])
+ const nestedReferenceTableQueries = useReferenceTableMetadata(
+ workspaceId,
+ nestedReferenceTableIds
+ )
+ const { referenceTables, referenceTableNames } = useMemo(() => {
+ const tables = new Map ()
+ const names = new Map()
+ for (const table of directReferenceTables) {
+ tables.set(table.id, table)
+ names.set(table.id, table.name)
+ }
+ for (const { data: table } of nestedReferenceTableQueries) {
+ if (!table) continue
+ tables.set(table.id, table)
+ names.set(table.id, table.name)
+ }
+ return { referenceTables: tables, referenceTableNames: names }
+ }, [directReferenceTables, nestedReferenceTableQueries])
/** Sort is single-column, so only the first spec entry can be active. */
const activeSort = queryOptions.sort?.[0]
@@ -884,8 +919,8 @@ export function TableGrid({
const hidden = new Set(hiddenColumns)
ordered = ordered.filter((col) => !hidden.has(getColumnId(col)))
}
- return expandToDisplayColumns(ordered, tableWorkflowGroups)
- }, [columns, columnOrder, hiddenColumns, tableWorkflowGroups])
+ return expandToDisplayColumns(ordered, tableWorkflowGroups, referenceTableNames)
+ }, [columns, columnOrder, hiddenColumns, tableWorkflowGroups, referenceTableNames])
const activeExpandedReference = useMemo(() => {
if (!expandedReference) return null
@@ -901,7 +936,51 @@ export function TableGrid({
? expandedReference
: null
}, [displayColumns, rows, expandedReference])
- const expandedSourceRowId = activeExpandedReference?.sourceRowId ?? null
+ const referencePreviewTable = activeExpandedReference
+ ? referenceTables.get(activeExpandedReference.referenceTableId)
+ : undefined
+ const referencePreviewTableQuery = activeExpandedReference
+ ? referenceTableQueries[referenceTableIds.indexOf(activeExpandedReference.referenceTableId)]
+ : undefined
+ const referencePreviewTableStatus: ReferenceTableLoadStatus = referencePreviewTable
+ ? 'ready'
+ : referencePreviewTableQuery?.isError
+ ? 'error'
+ : referencePreviewTableQuery?.isSuccess
+ ? 'ready'
+ : 'loading'
+ const referencePreviewQuery = useReferenceRowPreview(
+ workspaceId,
+ activeExpandedReference?.referenceTableId,
+ activeExpandedReference?.referenceRowId,
+ activeExpandedReference?.sourceRowId,
+ activeExpandedReference?.sourceColumnKey
+ )
+ const loadedReferencePreviewTarget: ReferencePreviewTarget | null = referencePreviewQuery.data
+ ? {
+ sourceRowId: referencePreviewQuery.data.sourceRowId,
+ sourceColumnKey: referencePreviewQuery.data.sourceColumnKey,
+ referenceTableId: referencePreviewQuery.data.tableId,
+ referenceRowId: referencePreviewQuery.data.rowId,
+ }
+ : null
+ const displayedReferencePreview = resolveDisplayedReferencePreviewTarget({
+ activeTarget: activeExpandedReference,
+ loadedTarget: loadedReferencePreviewTarget,
+ isFetching: referencePreviewQuery.isFetching,
+ isError: referencePreviewQuery.isError,
+ })
+ const expandedSourceRowId = displayedReferencePreview?.sourceRowId ?? null
+ const displayedReferenceTable = referencePreviewQuery.isError
+ ? referencePreviewTable
+ : referencePreviewQuery.data?.table
+ const displayedReferenceRow = referencePreviewQuery.isError
+ ? undefined
+ : referencePreviewQuery.data?.row
+ const displayedReferenceTableStatus: Exclude =
+ referencePreviewQuery.isError && referencePreviewTableStatus === 'error' ? 'error' : 'ready'
+ const displayedReferenceRowError =
+ referencePreviewQuery.isError && referencePreviewTableStatus !== 'error'
const rowVirtualizer = useVirtualizer({
count: rows.length,
@@ -4982,6 +5061,10 @@ export function TableGrid({
activeExpandedReference?.sourceRowId === row.id
? activeExpandedReference
: null
+ const displayedRowReference =
+ displayedReferencePreview?.sourceRowId === row.id
+ ? displayedReferencePreview
+ : null
return (
- {rowReference ? (
+ {displayedRowReference ? (
) : null}
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 0fd4875184a..3b0388e1d70 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts
@@ -22,6 +22,8 @@ export interface ColumnSourceInfo {
export interface DisplayColumn extends ColumnDefinition {
/** Stable per-visual-column identifier (= column.name). */
key: string
+ /** Display name of the table targeted by a reference column. */
+ referenceTableName?: string
/** Block id producing this column's value (workflow-output columns only). */
outputBlockId?: string
/** Pluck path the workflow ran for this column. */
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 93954776f02..ef0e68314bf 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts
@@ -12,10 +12,13 @@ import {
buildTableSelectionContext,
canWriteRowsWithChip,
chipRowCount,
+ collectReferenceTableIds,
columnNameIssue,
drainTargetForChip,
+ expandToDisplayColumns,
horizontalEdgeScrollVelocity,
isSameReferencePreviewTarget,
+ resolveDisplayedReferencePreviewTarget,
selectedColumnIds,
} from './utils'
@@ -28,6 +31,128 @@ function columns(count: number): DisplayColumn[] {
const rowIds = (count: number) => Array.from({ length: count }, (_, i) => `r${i}`)
+describe('expandToDisplayColumns', () => {
+ it('attaches the referenced table name to reference display columns', () => {
+ const [column] = expandToDisplayColumns(
+ [
+ {
+ id: 'account-column',
+ name: 'Account',
+ type: 'reference',
+ referenceTableId: 'accounts-table',
+ },
+ ],
+ [],
+ new Map([['accounts-table', 'Accounts']])
+ )
+
+ expect(column).toMatchObject({ referenceTableName: 'Accounts' })
+ })
+})
+
+describe('collectReferenceTableIds', () => {
+ it('returns each referenced table once in stable order', () => {
+ expect(
+ collectReferenceTableIds([
+ { id: 'name', name: 'Name', type: 'string' },
+ {
+ id: 'owner',
+ name: 'Owner',
+ type: 'reference',
+ referenceTableId: 'table-owners',
+ },
+ {
+ id: 'account',
+ name: 'Account',
+ type: 'reference',
+ referenceTableId: 'table-accounts',
+ },
+ {
+ id: 'backup-owner',
+ name: 'Backup owner',
+ type: 'reference',
+ referenceTableId: 'table-owners',
+ },
+ ])
+ ).toEqual(['table-accounts', 'table-owners'])
+ })
+})
+
+describe('resolveDisplayedReferencePreviewTarget', () => {
+ const first = {
+ sourceRowId: 'row-1',
+ sourceColumnKey: 'account',
+ referenceTableId: 'table-accounts',
+ referenceRowId: 'account-1',
+ }
+ const second = {
+ sourceRowId: 'row-2',
+ sourceColumnKey: 'owner',
+ referenceTableId: 'table-owners',
+ referenceRowId: 'owner-1',
+ }
+
+ it('keeps the completed preview visible while a different target fetches', () => {
+ expect(
+ resolveDisplayedReferencePreviewTarget({
+ activeTarget: second,
+ loadedTarget: first,
+ isFetching: true,
+ isError: false,
+ })
+ ).toEqual(first)
+ })
+
+ it('waits on an initial fetch or same-target refresh', () => {
+ expect(
+ resolveDisplayedReferencePreviewTarget({
+ activeTarget: first,
+ loadedTarget: null,
+ isFetching: true,
+ isError: false,
+ })
+ ).toBeNull()
+ expect(
+ resolveDisplayedReferencePreviewTarget({
+ activeTarget: first,
+ loadedTarget: first,
+ isFetching: true,
+ isError: false,
+ })
+ ).toBeNull()
+ })
+
+ it('switches atomically on completion and reveals terminal errors', () => {
+ expect(
+ resolveDisplayedReferencePreviewTarget({
+ activeTarget: second,
+ loadedTarget: second,
+ isFetching: false,
+ isError: false,
+ })
+ ).toEqual(second)
+ expect(
+ resolveDisplayedReferencePreviewTarget({
+ activeTarget: second,
+ loadedTarget: first,
+ isFetching: false,
+ isError: true,
+ })
+ ).toEqual(second)
+ })
+
+ it('closes when there is no active target', () => {
+ expect(
+ resolveDisplayedReferencePreviewTarget({
+ activeTarget: null,
+ loadedTarget: first,
+ isFetching: false,
+ isError: false,
+ })
+ ).toBeNull()
+ })
+})
+
describe('isSameReferencePreviewTarget', () => {
const target = {
sourceRowId: 'source-row',
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 a04b28fc0d0..2f2cfcc7fc5 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,6 +12,7 @@ import type {
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
+import { columnTypeOf } from '@/lib/table/column-types'
import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps'
import type {
@@ -34,6 +35,38 @@ export type RowSelection =
export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' }
export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' }
+export type ReferenceTableLoadStatus = 'loading' | 'error' | 'ready'
+
+export function collectReferenceTableIds(columns: ColumnDefinition[]): string[] {
+ const ids = new Set()
+ for (const column of columns) {
+ const referencePreview = columnTypeOf(column).referencePreview
+ const tableId = referencePreview?.getTableId(column)
+ if (tableId) ids.add(tableId)
+ }
+ return Array.from(ids).sort()
+}
+
+interface DisplayedReferencePreviewInput {
+ activeTarget: ReferencePreviewTarget | null
+ loadedTarget: ReferencePreviewTarget | null
+ isFetching: boolean
+ isError: boolean
+}
+
+export function resolveDisplayedReferencePreviewTarget({
+ activeTarget,
+ loadedTarget,
+ isFetching,
+ isError,
+}: DisplayedReferencePreviewInput): ReferencePreviewTarget | null {
+ if (!activeTarget) return null
+ if (isError) return activeTarget
+ if (!loadedTarget) return null
+ if (isSameReferencePreviewTarget(activeTarget, loadedTarget) && isFetching) return null
+ return loadedTarget
+}
+
export function isSameReferencePreviewTarget(
left: ReferencePreviewTarget | null,
right: ReferencePreviewTarget
@@ -180,7 +213,8 @@ export type HeaderGroup =
*/
export function expandToDisplayColumns(
columns: ColumnDefinition[],
- workflowGroups: WorkflowGroup[]
+ workflowGroups: WorkflowGroup[],
+ referenceTableNames?: ReadonlyMap
): DisplayColumn[] {
const out: DisplayColumn[] = []
const groupById = new Map(workflowGroups.map((g) => [g.id, g]))
@@ -209,6 +243,9 @@ export function expandToDisplayColumns(
out.push({
...child,
key: getColumnId(child),
+ referenceTableName: child.referenceTableId
+ ? referenceTableNames?.get(child.referenceTableId)
+ : undefined,
outputBlockId: output?.blockId,
outputPath: output?.path,
groupSize: size,
@@ -222,6 +259,9 @@ export function expandToDisplayColumns(
out.push({
...column,
key: getColumnId(column),
+ referenceTableName: column.referenceTableId
+ ? referenceTableNames?.get(column.referenceTableId)
+ : undefined,
groupSize: 1,
groupStartColIndex: out.length,
headerLabel: column.name,
diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts
index 200ae0e20d4..56e48685b4b 100644
--- a/apps/sim/hooks/queries/tables.test.ts
+++ b/apps/sim/hooks/queries/tables.test.ts
@@ -2,7 +2,7 @@
* @vitest-environment node
*/
-import { useQuery } from '@tanstack/react-query'
+import { useQueries, useQuery } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { queryClient, cacheStore } = vi.hoisted(() => {
@@ -27,6 +27,7 @@ const { queryClient, cacheStore } = vi.hoisted(() => {
.filter(([k]) => k.startsWith(prefix))
.map(([k, v]) => [JSON.parse(k), v])
}),
+ ensureQueryData: vi.fn(),
removeQueries: vi.fn(),
},
}
@@ -36,6 +37,7 @@ vi.mock('@tanstack/react-query', () => ({
keepPreviousData: {},
infiniteQueryOptions: (opts: unknown) => opts,
useQuery: vi.fn(),
+ useQueries: vi.fn(() => []),
useInfiniteQuery: vi.fn(),
useQueryClient: vi.fn(() => queryClient),
useMutation: vi.fn((options) => options),
@@ -61,12 +63,18 @@ vi.mock('@sim/emcn', () => ({
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
-import { getTableRowContract, type TableViewWire } from '@/lib/api/contracts/tables'
+import {
+ getTableContract,
+ getTableRowContract,
+ type TableViewWire,
+} from '@/lib/api/contracts/tables'
import {
tableRowsInfiniteOptions,
tableRowsParamsKey,
useBatchUpdateTableRows,
useDeleteColumn,
+ useReferenceRowPreview,
+ useReferenceTableMetadata,
useRestoreTable,
useTableRow,
useUpdateColumn,
@@ -155,6 +163,125 @@ describe('useTableRow', () => {
})
})
+describe('useReferenceRowPreview', () => {
+ function getQueryOptions() {
+ return vi.mocked(useQuery).mock.calls.at(-1)?.[0] as {
+ enabled: boolean
+ gcTime: number
+ queryKey: readonly unknown[]
+ placeholderData: unknown
+ refetchOnMount: 'always'
+ refetchOnReconnect: boolean
+ refetchOnWindowFocus: boolean
+ staleTime: number
+ queryFn: (context: { signal: AbortSignal }) => Promise
+ }
+ }
+
+ it('isolates each opening and fetches only the referenced row', async () => {
+ const row = { id: 'row-1', data: { name: 'Acme' } }
+ const table = { id: TABLE_ID, name: 'Accounts', schema: { columns: [] } }
+ const signal = new AbortController().signal
+ queryClient.ensureQueryData.mockResolvedValueOnce(table)
+ vi.mocked(requestJson).mockResolvedValueOnce({ data: { row } })
+
+ useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, row.id, 'source-row-1', 'account')
+
+ const options = getQueryOptions()
+ expect(options).toMatchObject({
+ enabled: true,
+ gcTime: 0,
+ placeholderData: expect.anything(),
+ queryKey: tableKeys.referencePreview(TABLE_ID, row.id, 'source-row-1', 'account'),
+ refetchOnMount: 'always',
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ })
+ await expect(options.queryFn({ signal })).resolves.toEqual({
+ tableId: TABLE_ID,
+ rowId: row.id,
+ sourceRowId: 'source-row-1',
+ sourceColumnKey: 'account',
+ table,
+ row,
+ })
+ expect(queryClient.ensureQueryData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: tableKeys.detail(TABLE_ID),
+ staleTime: Number.POSITIVE_INFINITY,
+ })
+ )
+ expect(requestJson).toHaveBeenCalledOnce()
+ expect(requestJson).toHaveBeenCalledWith(getTableRowContract, {
+ params: { tableId: TABLE_ID, rowId: row.id },
+ query: { workspaceId: WORKSPACE_ID },
+ signal,
+ })
+ })
+
+ it('does not fetch until every referenced-row identity is available', () => {
+ useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, undefined)
+
+ expect(getQueryOptions().enabled).toBe(false)
+ })
+
+ it('uses the source cell to identify each preview opening', () => {
+ useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, 'row-1', 'source-row-1', 'account')
+ const firstOpening = getQueryOptions().queryKey
+
+ useReferenceRowPreview(WORKSPACE_ID, TABLE_ID, 'row-1', 'source-row-2', 'account')
+
+ expect(getQueryOptions().queryKey).not.toEqual(firstOpening)
+ })
+})
+
+describe('useReferenceTableMetadata', () => {
+ it('prefetches only distinct referenced tables through the shared detail cache', async () => {
+ useReferenceTableMetadata(WORKSPACE_ID, ['table-z', 'table-a', 'table-z'])
+
+ const queries = vi.mocked(useQueries).mock.calls.at(-1)?.[0].queries as Array<{
+ enabled: boolean
+ queryFn: (context: { signal: AbortSignal }) => Promise
+ queryKey: readonly unknown[]
+ refetchOnWindowFocus: boolean
+ staleTime: number
+ }>
+ expect(queries.map(({ queryKey }) => queryKey)).toEqual([
+ tableKeys.detail('table-a'),
+ tableKeys.detail('table-z'),
+ ])
+ expect(queries).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ enabled: true,
+ refetchOnWindowFocus: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ }),
+ ])
+ )
+
+ const signal = new AbortController().signal
+ const table = { id: 'table-a', name: 'Accounts', schema: { columns: [] } }
+ vi.mocked(requestJson).mockResolvedValueOnce({ data: { table } })
+ await expect(queries[0].queryFn({ signal })).resolves.toEqual(table)
+ expect(requestJson).toHaveBeenCalledWith(getTableContract, {
+ params: { tableId: 'table-a' },
+ query: { workspaceId: WORKSPACE_ID },
+ signal,
+ })
+ })
+
+ it('keeps metadata prefetch disabled without a workspace', () => {
+ useReferenceTableMetadata(undefined, ['table-a'])
+
+ const queries = vi.mocked(useQueries).mock.calls.at(-1)?.[0].queries as Array<{
+ enabled: boolean
+ }>
+ expect(queries[0].enabled).toBe(false)
+ })
+})
+
describe('useBatchUpdateTableRows', () => {
it('invalidates cached row details after a batch write settles', () => {
const hook = useBatchUpdateTableRows({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 3fc9aae6935..255b0034083 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -12,6 +12,7 @@ import {
keepPreviousData,
useInfiniteQuery,
useMutation,
+ useQueries,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
@@ -146,6 +147,9 @@ export const TABLE_FIND_STALE_TIME = 30 * 1000
export const TABLE_FIND_GC_TIME = 60 * 1000
export const TABLE_ROWS_STALE_TIME = 30 * 1000
export const TABLE_EXPORT_JOBS_STALE_TIME = 5 * 1000
+export const TABLE_REFERENCE_PREVIEW_STALE_TIME = Number.POSITIVE_INFINITY
+export const TABLE_REFERENCE_PREVIEW_GC_TIME = 0
+export const TABLE_REFERENCE_METADATA_STALE_TIME = Number.POSITIVE_INFINITY
type TableRowsParams = Omit &
TableIdParamsInput & {
@@ -341,6 +345,7 @@ export function useTableRow(
tableId: string | undefined,
rowId: string | undefined
) {
+ // rq-lint-allow: tableId and rowId are globally unique; workspaceId is only an authz scope on the fetch and cannot collide across workspaces
return useQuery({
queryKey: tableKeys.row(tableId ?? '', rowId ?? ''),
queryFn: ({ signal }) =>
@@ -350,6 +355,53 @@ export function useTableRow(
})
}
+/**
+ * Fetches an isolated table-and-row snapshot for one reference preview opening.
+ *
+ * Referenced table metadata normally comes from detail queries loaded with the grid, so ensuring it
+ * here reuses the cache while making the schema and row one atomic result. Previous complete data
+ * remains visible when the query key changes, allowing the grid to switch previews only after the
+ * next target settles. The preview key remains outside ordinary row roots so active-table mutations
+ * cannot replace the open snapshot.
+ */
+export function useReferenceRowPreview(
+ workspaceId: string | undefined,
+ tableId: string | undefined,
+ rowId: string | undefined,
+ sourceRowId?: string,
+ sourceColumnKey?: string
+) {
+ const queryClient = useQueryClient()
+ // rq-lint-allow: tableId is globally unique; workspaceId is only an authz scope on the fetch and cannot collide across workspaces
+ return useQuery({
+ queryKey: tableKeys.referencePreview(tableId ?? '', rowId ?? '', sourceRowId, sourceColumnKey),
+ queryFn: async ({ signal }) => {
+ const [table, row] = await Promise.all([
+ queryClient.ensureQueryData({
+ ...getTableDetailQueryOptions(workspaceId as string, tableId as string),
+ staleTime: TABLE_REFERENCE_METADATA_STALE_TIME,
+ }),
+ fetchTableRow(workspaceId as string, tableId as string, rowId as string, signal),
+ ])
+ return {
+ tableId: tableId as string,
+ rowId: rowId as string,
+ sourceRowId: sourceRowId as string,
+ sourceColumnKey: sourceColumnKey as string,
+ table,
+ row,
+ }
+ },
+ enabled: Boolean(workspaceId && tableId && rowId && sourceRowId && sourceColumnKey),
+ placeholderData: keepPreviousData,
+ staleTime: TABLE_REFERENCE_PREVIEW_STALE_TIME,
+ gcTime: TABLE_REFERENCE_PREVIEW_GC_TIME,
+ refetchOnMount: 'always',
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ })
+}
+
/**
* Shared table-detail query options so non-component callers (e.g. selector
* providers) can `ensureQueryData` the same cache entry `useTable` populates.
@@ -362,6 +414,27 @@ export function getTableDetailQueryOptions(workspaceId: string, tableId: string)
}
}
+/**
+ * Prefetches each referenced table's name and schema when its source grid loads.
+ * The detail keys are shared with {@link useTable}, so cached definitions are reused and
+ * schema invalidations still refresh active observers.
+ */
+export function useReferenceTableMetadata(
+ workspaceId: string | undefined,
+ tableIds: ReadonlyArray
+) {
+ const uniqueTableIds = Array.from(new Set(tableIds)).sort()
+ // rq-lint-allow: table IDs are globally unique; workspaceId is only an authz scope on each detail fetch
+ return useQueries({
+ queries: uniqueTableIds.map((tableId) => ({
+ ...getTableDetailQueryOptions(workspaceId ?? '', tableId),
+ enabled: Boolean(workspaceId),
+ staleTime: TABLE_REFERENCE_METADATA_STALE_TIME,
+ refetchOnWindowFocus: false,
+ })),
+ })
+}
+
export interface TableRunState {
dispatches: ActiveDispatch[]
runningByRowId: Record
diff --git a/apps/sim/hooks/queries/utils/table-keys.ts b/apps/sim/hooks/queries/utils/table-keys.ts
index a7697e3ff9d..32567455032 100644
--- a/apps/sim/hooks/queries/utils/table-keys.ts
+++ b/apps/sim/hooks/queries/utils/table-keys.ts
@@ -26,6 +26,9 @@ export const tableKeys = {
[...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const,
rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const,
row: (tableId: string, rowId: string) => [...tableKeys.rowsRoot(tableId), 'row', rowId] as const,
+ referencePreviews: () => [...tableKeys.all, 'reference-preview'] as const,
+ referencePreview: (tableId: string, rowId: string, sourceRowId = '', sourceColumnKey = '') =>
+ [...tableKeys.referencePreviews(), tableId, rowId, sourceRowId, sourceColumnKey] as const,
/**
* Prefix covering only the paged row lists. `rowsRoot` is a shared parent — `find`
* hangs off it holding a different shape — so anything walking the cache for row
diff --git a/apps/sim/lib/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts
index 5d8c6523bd6..932efbd0daf 100644
--- a/apps/sim/lib/table/column-types/reference.ts
+++ b/apps/sim/lib/table/column-types/reference.ts
@@ -16,9 +16,6 @@ export const referenceColumnType: ColumnTypeDefinition = {
editor: 'text',
expandable: false,
referencePreview: {
- getChipLabel(column) {
- return column.name
- },
getTableId(column) {
return column.referenceTableId
},
diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts
index f9cb14b7a82..48831cd6621 100644
--- a/apps/sim/lib/table/column-types/types.ts
+++ b/apps/sim/lib/table/column-types/types.ts
@@ -75,7 +75,6 @@ export type CoerceResult = { ok: true; value: JsonValue } | { ok: false }
/** Client-side behavior for a column whose stored value can open a referenced row preview. */
export interface ColumnReferencePreviewDefinition {
- getChipLabel(column: ColumnDefinition): string
getTableId(column: ColumnDefinition): string | undefined
getRowId(value: unknown): string | null
}
From 884d43f93e0f98fc0631f21cddf61ae5917940e7 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Fri, 28 Aug 2026 20:16:49 -0700
Subject: [PATCH 11/12] fix(tables): preserve foreign key integrity
---
.../lib/copy/copy-resources.test.ts | 414 ++++++++++++++++
.../lib/copy/copy-resources.ts | 208 +++++++-
.../lib/promote/copy-unmapped.test.ts | 8 +-
.../lib/promote/copy-unmapped.ts | 1 +
.../lib/remap/remap-table-groups.ts | 12 +
.../lib/copilot/generated/tool-catalog-v1.ts | 433 +++++++++++++++--
.../lib/copilot/generated/tool-schemas-v1.ts | 456 ++++++++++++++++--
apps/sim/lib/folders/bulk.test.ts | 1 +
apps/sim/lib/folders/bulk.ts | 18 +-
apps/sim/lib/folders/cascade.test.ts | 53 +-
apps/sim/lib/folders/config.ts | 43 +-
apps/sim/lib/table/application/bulk.test.ts | 84 ++++
apps/sim/lib/table/application/bulk.ts | 73 ++-
.../column-types/registry.server.test.ts | 79 ++-
.../lib/table/column-types/registry.server.ts | 129 ++++-
.../lib/table/column-types/types.server.ts | 8 +
apps/sim/lib/table/service.test.ts | 77 ++-
apps/sim/lib/table/service.ts | 80 +--
18 files changed, 2007 insertions(+), 170 deletions(-)
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..c76e38539f5 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
@@ -54,6 +54,7 @@ import {
copyForkResourceContainers,
copyForkResourceContent,
type ForkContentPlan,
+ MAX_FORK_TABLES_WITH_DEPENDENCIES,
planForkMappedKbDocumentCopies,
} from '@/ee/workspace-forking/lib/copy/copy-resources'
import type { ForkReferenceResolver } from '@/ee/workspace-forking/lib/remap/remap-references'
@@ -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,319 @@ describe('copyForkResourceContent', () => {
})
describe('copyForkResourceContainers table views', () => {
+ it('rejects a mapped referenced table when row mappings are unavailable', async () => {
+ const now = new Date('2026-08-19T00:00:00.000Z')
+ const selectedDefinition = {
+ id: 'table-orders',
+ workspaceId: 'src-ws',
+ folderId: null,
+ name: 'Orders',
+ description: null,
+ schema: {
+ columns: [
+ {
+ id: 'col-account',
+ name: 'Account',
+ type: 'reference',
+ referenceTableId: 'table-accounts',
+ },
+ ],
+ },
+ metadata: {},
+ maxRows: 10000,
+ rowCount: 1,
+ rowsVersion: 1,
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+ archivedAt: null,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ }
+ const insert = vi.fn()
+ const tx = {
+ select: () => ({
+ from: () => ({ where: () => Promise.resolve([selectedDefinition]) }),
+ }),
+ insert,
+ }
+
+ await expect(
+ copyForkResourceContainers({
+ tx: tx as unknown as DbOrTx,
+ sourceWorkspaceId: 'src-ws',
+ childWorkspaceId: 'child-ws',
+ userId: 'user-1',
+ now,
+ selection: {
+ customTools: [],
+ skills: [],
+ mcpServers: [],
+ workflowMcpServers: [],
+ tables: ['table-orders'],
+ knowledgeBases: [],
+ },
+ workflowIdMap: new Map(),
+ resolveMappedTableReference: (sourceTableId) =>
+ sourceTableId === 'table-accounts' ? 'target-accounts' : null,
+ documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
+ })
+ ).rejects.toThrow(
+ 'Referenced table table-accounts is mapped to target-accounts, but referenced row mappings are unavailable'
+ )
+ expect(insert).not.toHaveBeenCalled()
+ })
+
+ it('rejects an unavailable referenced-table dependency before inserting copies', async () => {
+ const now = new Date('2026-08-19T00:00:00.000Z')
+ const selectedDefinition = {
+ id: 'table-orders',
+ workspaceId: 'src-ws',
+ folderId: null,
+ name: 'Orders',
+ description: null,
+ schema: {
+ columns: [
+ {
+ id: 'col-account',
+ name: 'Account',
+ type: 'reference',
+ referenceTableId: 'table-accounts',
+ },
+ ],
+ },
+ metadata: {},
+ maxRows: 10000,
+ rowCount: 1,
+ rowsVersion: 1,
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+ archivedAt: null,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ }
+ const insert = vi.fn()
+ let definitionRead = 0
+ const tx = {
+ select: () => ({
+ from: () => ({
+ where: () => Promise.resolve(definitionRead++ === 0 ? [selectedDefinition] : []),
+ }),
+ }),
+ insert,
+ }
+
+ await expect(
+ copyForkResourceContainers({
+ tx: tx as unknown as DbOrTx,
+ sourceWorkspaceId: 'src-ws',
+ childWorkspaceId: 'child-ws',
+ userId: 'user-1',
+ now,
+ selection: {
+ customTools: [],
+ skills: [],
+ mcpServers: [],
+ workflowMcpServers: [],
+ tables: ['table-orders'],
+ knowledgeBases: [],
+ },
+ workflowIdMap: new Map(),
+ documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
+ })
+ ).rejects.toThrow('Referenced table table-accounts is unavailable for copy')
+ expect(insert).not.toHaveBeenCalled()
+ })
+
+ it('bounds the expanded referenced-table dependency set', async () => {
+ const tx = { select: vi.fn(), insert: vi.fn() }
+
+ await expect(
+ copyForkResourceContainers({
+ tx: tx as unknown as DbOrTx,
+ sourceWorkspaceId: 'src-ws',
+ childWorkspaceId: 'child-ws',
+ userId: 'user-1',
+ now: new Date('2026-08-19T00:00:00.000Z'),
+ selection: {
+ customTools: [],
+ skills: [],
+ mcpServers: [],
+ workflowMcpServers: [],
+ tables: Array.from(
+ { length: MAX_FORK_TABLES_WITH_DEPENDENCIES + 1 },
+ (_, index) => `table-${index}`
+ ),
+ knowledgeBases: [],
+ },
+ workflowIdMap: new Map(),
+ documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
+ })
+ ).rejects.toThrow(
+ `Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies`
+ )
+ expect(tx.select).not.toHaveBeenCalled()
+ })
+
+ it('copies referenced tables transitively and remaps reference columns to their child ids', async () => {
+ const now = new Date('2026-08-19T00:00:00.000Z')
+ const definitions = [
+ {
+ id: 'table-orders',
+ workspaceId: 'src-ws',
+ folderId: null,
+ name: 'Orders',
+ description: null,
+ schema: {
+ columns: [
+ {
+ id: 'col-account',
+ name: 'Account',
+ type: 'reference',
+ referenceTableId: 'table-accounts',
+ },
+ ],
+ },
+ metadata: {},
+ maxRows: 10000,
+ rowCount: 1,
+ rowsVersion: 1,
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+ archivedAt: null,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ },
+ {
+ id: 'table-accounts',
+ workspaceId: 'src-ws',
+ folderId: null,
+ name: 'Accounts',
+ description: null,
+ schema: {
+ columns: [
+ {
+ id: 'col-company',
+ name: 'Company',
+ type: 'reference',
+ referenceTableId: 'table-companies',
+ },
+ ],
+ },
+ metadata: {},
+ maxRows: 10000,
+ rowCount: 1,
+ rowsVersion: 1,
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+ archivedAt: null,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ },
+ {
+ id: 'table-companies',
+ workspaceId: 'src-ws',
+ folderId: null,
+ name: 'Companies',
+ description: null,
+ schema: { columns: [{ id: 'col-name', name: 'Name', type: 'string' }] },
+ metadata: {},
+ maxRows: 10000,
+ rowCount: 1,
+ rowsVersion: 1,
+ schemaLocked: false,
+ insertLocked: false,
+ updateLocked: false,
+ deleteLocked: false,
+ archivedAt: null,
+ createdBy: 'source-user',
+ createdAt: now,
+ updatedAt: now,
+ },
+ ]
+ const inserted = new Map>>()
+ let definitionRead = 0
+ const tx = {
+ select: () => ({
+ from: (table: unknown) => ({
+ where: () => {
+ if (table === tableViews) return Promise.resolve([])
+ if (table !== userTableDefinitions) return Promise.resolve([])
+ const rows = [definitions[definitionRead]].filter(Boolean)
+ definitionRead += 1
+ return Promise.resolve(rows)
+ },
+ }),
+ }),
+ insert: (table: unknown) => ({
+ values: (values: Array>) => {
+ inserted.set(table, values)
+ return Promise.resolve()
+ },
+ }),
+ }
+
+ const result = await copyForkResourceContainers({
+ tx: tx as unknown as DbOrTx,
+ sourceWorkspaceId: 'src-ws',
+ childWorkspaceId: 'child-ws',
+ userId: 'user-1',
+ now,
+ selection: {
+ customTools: [],
+ skills: [],
+ mcpServers: [],
+ workflowMcpServers: [],
+ tables: ['table-orders'],
+ knowledgeBases: [],
+ },
+ workflowIdMap: new Map(),
+ documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true },
+ })
+
+ const tableMap = result.idMap.get('table')
+ const childOrdersId = tableMap?.get('table-orders')
+ const childAccountsId = tableMap?.get('table-accounts')
+ const childCompaniesId = tableMap?.get('table-companies')
+ expect(tableMap?.size).toBe(3)
+ expect(result.names.tables).toEqual(['Orders', 'Accounts', 'Companies'])
+ expect(result.contentPlan.tables).toEqual([
+ {
+ sourceId: 'table-orders',
+ childId: childOrdersId,
+ dependsOnChildIds: [childAccountsId],
+ referenceColumnTargetTableIds: { 'col-account': childAccountsId },
+ },
+ {
+ sourceId: 'table-accounts',
+ childId: childAccountsId,
+ dependsOnChildIds: [childCompaniesId],
+ referenceColumnTargetTableIds: { 'col-company': childCompaniesId },
+ },
+ { sourceId: 'table-companies', childId: childCompaniesId },
+ ])
+
+ const copiedDefinitions = inserted.get(userTableDefinitions)
+ expect(copiedDefinitions).toHaveLength(3)
+ expect(
+ copiedDefinitions?.find((definition) => definition.id === childOrdersId)?.schema
+ ).toMatchObject({ columns: [{ referenceTableId: childAccountsId }] })
+ expect(
+ copiedDefinitions?.find((definition) => definition.id === childAccountsId)?.schema
+ ).toMatchObject({ columns: [{ referenceTableId: childCompaniesId }] })
+ })
+
it('copies saved views and seeds a default for a legacy table', async () => {
const now = new Date('2026-08-19T00:00:00.000Z')
const definitions = [
diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
index 54d01bc3b32..dac164396bb 100644
--- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
+++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts
@@ -55,6 +55,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 {
@@ -90,7 +92,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')
@@ -99,6 +104,8 @@ const CONTENT_PAGE = 500
const PROVENANCE_CONTENT_PAGE = 8
const MAX_FORK_PROVENANCE_ENTRIES = 10_000
const MAX_FORK_PROVENANCE_BYTES = 8 * 1024 * 1024
+/** Matches the fork contract's per-resource selection ceiling after dependencies are expanded. */
+export const MAX_FORK_TABLES_WITH_DEPENDENCIES = 2_000
function isForkProvenancePageWithinBudget(sidecars: readonly { entries: unknown }[]): boolean {
let entries = 0
@@ -216,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
@@ -237,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
@@ -288,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). */
@@ -359,6 +378,102 @@ 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,
+ referenceColumnTargetTableIds: Readonly> | undefined
+): unknown {
+ if (!referenceColumnTargetTableIds || !isRecordLike(data)) return data
+ let remapped: Record | undefined
+ for (const [columnId, childTableId] of Object.entries(referenceColumnTargetTableIds)) {
+ const sourceRowId = data[columnId]
+ if (typeof sourceRowId !== 'string' || sourceRowId.length === 0) continue
+ remapped ??= { ...data }
+ remapped[columnId] = deriveCopiedTableRowId(childTableId, sourceRowId)
+ }
+ return remapped ?? data
+}
+
+/**
+ * Loads the selected tables plus the transitive closure of tables named by their reference
+ * columns. Each layer is workspace-scoped and active-only; an unavailable dependency fails the
+ * copy instead of persisting a source-workspace table id into the child schema.
+ */
+async function loadTableDefinitionsWithDependencies(
+ tx: DbOrTx,
+ sourceWorkspaceId: string,
+ selectedTableIds: readonly string[],
+ resolveMappedTableReference?: (sourceTableId: string) => string | null | undefined
+): Promise> {
+ const orderedIds = [...new Set(selectedTableIds)]
+ if (orderedIds.length > MAX_FORK_TABLES_WITH_DEPENDENCIES) {
+ throw new Error(
+ `Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies`
+ )
+ }
+ const scheduledIds = new Set(orderedIds)
+ const dependencyIds = new Set()
+ const definitionsById = new Map()
+ let pendingIds = [...orderedIds]
+
+ while (pendingIds.length > 0) {
+ const batchIds = pendingIds
+ const batchIdSet = new Set(batchIds)
+ pendingIds = []
+ const rows = await tx
+ .select()
+ .from(userTableDefinitions)
+ .where(
+ and(
+ inArray(userTableDefinitions.id, batchIds),
+ eq(userTableDefinitions.workspaceId, sourceWorkspaceId),
+ isNull(userTableDefinitions.archivedAt)
+ )
+ )
+ const batchRows = rows.filter((row) => batchIdSet.has(row.id))
+
+ for (const row of batchRows) {
+ definitionsById.set(row.id, row)
+ const referencedIds = collectColumnReferencedTableIds((row.schema as TableSchema).columns)
+ for (const referencedId of referencedIds) {
+ dependencyIds.add(referencedId)
+ if (scheduledIds.has(referencedId)) continue
+ const mappedTableId = resolveMappedTableReference?.(referencedId)
+ if (mappedTableId) {
+ throw new Error(
+ `Referenced table ${referencedId} is mapped to ${mappedTableId}, but referenced row mappings are unavailable`
+ )
+ }
+ if (scheduledIds.size >= MAX_FORK_TABLES_WITH_DEPENDENCIES) {
+ throw new Error(
+ `Cannot copy more than ${MAX_FORK_TABLES_WITH_DEPENDENCIES} tables including referenced dependencies`
+ )
+ }
+ scheduledIds.add(referencedId)
+ orderedIds.push(referencedId)
+ pendingIds.push(referencedId)
+ }
+ }
+
+ const missingDependencyId = batchIds.find(
+ (id) => dependencyIds.has(id) && !definitionsById.has(id)
+ )
+ if (missingDependencyId) {
+ throw new Error(`Referenced table ${missingDependencyId} is unavailable for copy`)
+ }
+ }
+
+ return orderedIds.flatMap((id) => {
+ const definition = definitionsById.get(id)
+ return definition ? [definition] : []
+ })
+}
+
/**
* Copy the selected resources' **container rows** into the child workspace inside
* the fork transaction: custom tools, skills, and MCP server configs (each a
@@ -627,16 +742,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
@@ -671,12 +782,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,
@@ -733,8 +854,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)
@@ -1245,14 +1385,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,
+ table.referenceColumnTargetTableIds
+ ),
},
provenance: classification.mode === 'tracked' ? classification : undefined,
}
@@ -1295,6 +1438,29 @@ export async function copyForkResourceContent(params: {
}
}
+ const failedTableIds = new Set(
+ failures.flatMap((failure) => (failure.kind === 'table' ? [failure.childId] : []))
+ )
+ let foundFailedDependent = true
+ while (foundFailedDependent) {
+ foundFailedDependent = false
+ for (const table of contentPlan.tables) {
+ if (failedTableIds.has(table.childId)) continue
+ if (!table.dependsOnChildIds?.some((dependencyId) => failedTableIds.has(dependencyId))) {
+ continue
+ }
+ failedTableIds.add(table.childId)
+ failures.push({ kind: 'table', childId: table.childId })
+ copiedResources -= 1
+ failedResources += 1
+ foundFailedDependent = true
+ logger.warn(`[${requestId}] Failed copied table because a referenced table copy failed`, {
+ sourceTableId: table.sourceId,
+ childTableId: table.childId,
+ })
+ }
+ }
+
for (const kb of contentPlan.knowledgeBases) {
try {
await logSkippedConnectorDocuments(kb)
diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts
index c177ceee97d..b9f8d6b27fe 100644
--- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts
+++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.test.ts
@@ -272,6 +272,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
})
it('threads push orientation through the shared container and mapping boundaries', async () => {
+ const resolver = vi.fn((kind: ForkRemapKind, sourceId: string) =>
+ kind === 'table' && sourceId === 'mapped-table' ? 'target-table' : null
+ )
await copyPromoteUnmappedResources({
tx,
edge,
@@ -290,7 +293,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
},
workflowIdMap: new Map(),
folderIdMap: new Map(),
- resolver: () => null,
+ resolver,
resolveBlockId,
referencedDocumentIds: [],
})
@@ -303,6 +306,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => {
},
})
)
+ const containerParams = mockCopyForkResourceContainers.mock.calls.at(-1)?.[0]
+ expect(containerParams?.resolveMappedTableReference('mapped-table')).toBe('target-table')
+ expect(resolver).toHaveBeenCalledWith('table', 'mapped-table')
expect(mockPersistCopiedResourceMappings).toHaveBeenCalledWith(
expect.objectContaining({
edgeChildWorkspaceId: 'edge-child',
diff --git a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts
index 19269023429..02e546e917e 100644
--- a/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts
+++ b/apps/sim/ee/workspace-forking/lib/promote/copy-unmapped.ts
@@ -232,6 +232,7 @@ export async function copyPromoteUnmappedResources(params: {
// A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs
// rewritten through the same plan resolver that remaps subblock-value env refs.
resolveEnvName: (key) => resolver('env-var', key),
+ resolveMappedTableReference: (sourceTableId) => resolver('table', sourceTableId),
resolveBlockId,
documentMappingContext: {
edgeChildWorkspaceId: edge.childWorkspaceId,
diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts
index 592f0565cc7..c8fc6274dcd 100644
--- a/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts
+++ b/apps/sim/ee/workspace-forking/lib/remap/remap-table-groups.ts
@@ -1,3 +1,4 @@
+import { remapColumnReferencedTableIds } from '@/lib/table/column-types/registry.server'
import type { TableSchema } from '@/lib/table/types'
import {
deriveForkBlockId,
@@ -60,3 +61,14 @@ export function remapForkTableWorkflowGroups(
return { ...schema, columns, workflowGroups: remappedGroups }
}
+
+/** Rewrites copied reference columns to the copied target table identities. */
+export function remapForkTableReferences(
+ schema: TableSchema,
+ tableIdMap: ReadonlyMap
+): TableSchema {
+ const columns = remapColumnReferencedTableIds(schema.columns, tableIdMap)
+ return columns.some((column, index) => column !== schema.columns[index])
+ ? { ...schema, columns }
+ : schema
+}
diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
index bbb44542619..da8c6f6abf5 100644
--- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts
@@ -71,6 +71,7 @@ export interface ToolCatalogEntry {
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
+ | 'load_slide_layout'
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_knowledge_base'
@@ -199,6 +200,7 @@ export interface ToolCatalogEntry {
| 'load_deployment'
| 'load_integration_tool'
| 'load_skill'
+ | 'load_slide_layout'
| 'manage_credential'
| 'manage_custom_tool'
| 'manage_knowledge_base'
@@ -2348,32 +2350,67 @@ export const Ffmpeg: ToolCatalogEntry = {
type: 'string',
description: 'Target format/extension for convert (e.g. mp4, mp3, wav, gif).',
},
- height: {
- type: 'number',
- description:
- 'Target height in pixels (scale_pad). 16-4096, and width x height must not exceed 4096 x 2304.',
- minimum: 16,
- maximum: 4096,
- },
+ height: { type: 'number', description: 'Target height in pixels (scale_pad).' },
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
- maxItems: 20,
+ },
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: 'Canonical VFS table path when available.' },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: { type: 'string', description: 'Workspace table ID.' },
+ },
+ },
},
},
},
@@ -2405,7 +2442,8 @@ export const Ffmpeg: ToolCatalogEntry = {
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2414,6 +2452,11 @@ export const Ffmpeg: ToolCatalogEntry = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2425,7 +2468,12 @@ export const Ffmpeg: ToolCatalogEntry = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -2444,13 +2492,7 @@ export const Ffmpeg: ToolCatalogEntry = {
type: 'number',
description: 'Volume multiplier for the primary track (mix_audio / overlay_audio).',
},
- width: {
- type: 'number',
- description:
- 'Target width in pixels (scale_pad). 16-4096, and width x height must not exceed 4096 x 2304.',
- minimum: 16,
- maximum: 4096,
- },
+ width: { type: 'number', description: 'Target width in pixels (scale_pad).' },
},
required: ['operation', 'inputs'],
},
@@ -2527,22 +2569,64 @@ export const GenerateAudio: ToolCatalogEntry = {
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
},
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: 'Canonical VFS table path when available.' },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: { type: 'string', description: 'Workspace table ID.' },
+ },
+ },
+ },
},
},
instrumental: {
@@ -2562,7 +2646,8 @@ export const GenerateAudio: ToolCatalogEntry = {
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2571,6 +2656,11 @@ export const GenerateAudio: ToolCatalogEntry = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2582,7 +2672,12 @@ export const GenerateAudio: ToolCatalogEntry = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -2624,27 +2719,70 @@ export const GenerateImage: ToolCatalogEntry = {
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
},
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: 'Canonical VFS table path when available.' },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: { type: 'string', description: 'Workspace table ID.' },
+ },
+ },
+ },
},
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2653,6 +2791,11 @@ export const GenerateImage: ToolCatalogEntry = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2664,7 +2807,12 @@ export const GenerateImage: ToolCatalogEntry = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -2709,22 +2857,64 @@ export const GenerateVideo: ToolCatalogEntry = {
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
},
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: { type: 'string', description: 'Canonical VFS table path when available.' },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: { type: 'string', description: 'Workspace table ID.' },
+ },
+ },
+ },
},
},
model: {
@@ -2750,7 +2940,8 @@ export const GenerateVideo: ToolCatalogEntry = {
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2759,6 +2950,11 @@ export const GenerateVideo: ToolCatalogEntry = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2770,7 +2966,12 @@ export const GenerateVideo: ToolCatalogEntry = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -3143,6 +3344,24 @@ export const LoadSkill: ToolCatalogEntry = {
},
}
+export const LoadSlideLayout: ToolCatalogEntry = {
+ id: 'load_slide_layout',
+ name: 'load_slide_layout',
+ route: 'go',
+ mode: 'sync',
+ parameters: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ description:
+ "Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
+ },
+ },
+ required: ['name'],
+ },
+}
+
export const ManageCredential: ToolCatalogEntry = {
id: 'manage_credential',
name: 'manage_credential',
@@ -5433,7 +5652,53 @@ export const TableColumns: ToolCatalogEntry = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: [names], multiple?: true }; reference requires referenceTableId.',
+ properties: {
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
+ multiple: {
+ type: 'boolean',
+ description:
+ 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.',
+ },
+ name: { type: 'string' },
+ options: {
+ type: 'array',
+ description:
+ 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.',
+ items: { type: 'string' },
+ },
+ position: { type: 'integer' },
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
+ type: {
+ type: 'string',
+ description:
+ 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
+ },
+ unique: {
+ type: 'boolean',
+ description:
+ 'Set or clear the column unique constraint (update_column; not supported on select columns)',
+ },
+ },
+ required: ['name', 'type'],
},
columnName: {
type: 'string',
@@ -5445,6 +5710,11 @@ export const TableColumns: ToolCatalogEntry = {
description:
'Array of column names to delete at once (preferred for multi-column delete_column)',
},
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
multiple: {
type: 'boolean',
description:
@@ -5454,7 +5724,17 @@ export const TableColumns: ToolCatalogEntry = {
newType: {
type: 'string',
description:
- 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.',
+ 'New column type for update_column: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
},
options: {
type: 'array',
@@ -5467,6 +5747,11 @@ export const TableColumns: ToolCatalogEntry = {
description:
'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.',
},
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
tableId: { type: 'string', description: 'Table ID (required for every operation)' },
unique: {
type: 'boolean',
@@ -5633,7 +5918,7 @@ export const TableManage: ToolCatalogEntry = {
schema: {
type: 'object',
description:
- 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.',
+ 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; currency takes currencyCode?, reference requires referenceTableId, and select requires options (display names) and takes multiple?.',
},
tableId: {
type: 'string',
@@ -6038,7 +6323,52 @@ export const UserTable: ToolCatalogEntry = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.',
+ properties: {
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
+ multiple: {
+ type: 'boolean',
+ description:
+ 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.',
+ },
+ name: { type: 'string' },
+ options: {
+ type: 'array',
+ description:
+ 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.',
+ items: { type: 'string' },
+ },
+ position: { type: 'integer' },
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
+ type: {
+ type: 'string',
+ description:
+ 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
+ },
+ unique: {
+ type: 'boolean',
+ description: 'Set column unique constraint (optional for update_column)',
+ },
+ },
+ required: ['name', 'type'],
},
columnName: {
type: 'string',
@@ -6050,6 +6380,11 @@ export const UserTable: ToolCatalogEntry = {
description:
'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.',
},
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
cursor: {
type: 'string',
description:
@@ -6181,7 +6516,17 @@ export const UserTable: ToolCatalogEntry = {
newType: {
type: 'string',
description:
- 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
+ 'New column type (optional for update_column). Types: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
},
options: {
type: 'array',
@@ -6246,6 +6591,11 @@ export const UserTable: ToolCatalogEntry = {
description:
'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.',
},
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
rowId: {
type: 'string',
description:
@@ -6270,7 +6620,7 @@ export const UserTable: ToolCatalogEntry = {
schema: {
type: 'object',
description:
- 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types: string, number, currency, boolean, date, json, select, reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.',
},
scope: {
type: 'string',
@@ -7041,6 +7391,7 @@ export const TOOL_CATALOG: Record = {
[LoadDeployment.id]: LoadDeployment,
[LoadIntegrationTool.id]: LoadIntegrationTool,
[LoadSkill.id]: LoadSkill,
+ [LoadSlideLayout.id]: LoadSlideLayout,
[ManageCredential.id]: ManageCredential,
[ManageCustomTool.id]: ManageCustomTool,
[ManageKnowledgeBase.id]: ManageKnowledgeBase,
diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
index c7f0cfcd3bf..01ffc349294 100644
--- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
+++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts
@@ -2301,30 +2301,74 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
height: {
type: 'number',
- description:
- 'Target height in pixels (scale_pad). 16-4096, and width x height must not exceed 4096 x 2304.',
- minimum: 16,
- maximum: 4096,
+ description: 'Target height in pixels (scale_pad).',
},
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
- maxItems: 20,
+ },
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description: 'Canonical VFS table path when available.',
+ },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: {
+ type: 'string',
+ description: 'Workspace table ID.',
+ },
+ },
+ },
},
},
},
@@ -2356,7 +2400,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2365,6 +2410,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2376,7 +2426,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -2403,10 +2458,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
width: {
type: 'number',
- description:
- 'Target width in pixels (scale_pad). 16-4096, and width x height must not exceed 4096 x 2304.',
- minimum: 16,
- maximum: 4096,
+ description: 'Target width in pixels (scale_pad).',
},
},
required: ['operation', 'inputs'],
@@ -2468,22 +2520,70 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
},
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description: 'Canonical VFS table path when available.',
+ },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: {
+ type: 'string',
+ description: 'Workspace table ID.',
+ },
+ },
+ },
+ },
},
},
instrumental: {
@@ -2503,7 +2603,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2512,6 +2613,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2523,7 +2629,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -2562,27 +2673,76 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
},
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description: 'Canonical VFS table path when available.',
+ },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: {
+ type: 'string',
+ description: 'Workspace table ID.',
+ },
+ },
+ },
+ },
},
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2591,6 +2751,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2602,7 +2767,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -2641,22 +2811,70 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
inputs: {
type: 'object',
description:
- 'Workspace files this tool reads. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
+ 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.',
properties: {
+ directories: {
+ type: 'array',
+ description:
+ 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description:
+ 'Canonical VFS folder path, e.g. "files/Reports". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.',
+ },
+ },
+ required: ['path'],
+ },
+ },
files: {
type: 'array',
- description: 'Workspace files to read, in the order this operation expects them.',
+ description: 'Workspace files to mount into the sandbox.',
items: {
type: 'object',
properties: {
path: {
type: 'string',
- description: 'Canonical VFS file path, e.g. "files/Reports/clip.mp4".',
+ description:
+ 'Canonical VFS file path, e.g. "files/Reports/sales.csv". By default this mounts at "/home/user/{path}".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.',
},
},
required: ['path'],
},
},
+ tables: {
+ type: 'array',
+ description: 'Workspace tables to mount as CSV files.',
+ items: {
+ type: 'object',
+ properties: {
+ path: {
+ type: 'string',
+ description: 'Canonical VFS table path when available.',
+ },
+ sandboxPath: {
+ type: 'string',
+ description: 'Optional full sandbox path for the mounted CSV.',
+ },
+ tableId: {
+ type: 'string',
+ description: 'Workspace table ID.',
+ },
+ },
+ },
+ },
},
},
model: {
@@ -2682,7 +2900,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
outputs: {
type: 'object',
- description: "Workspace files to create or overwrite with this tool's result.",
+ description:
+ 'Workspace files to create or overwrite from returned code results or sandbox-created files.',
properties: {
files: {
type: 'array',
@@ -2691,6 +2910,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
items: {
type: 'object',
properties: {
+ format: {
+ type: 'string',
+ description: 'Optional serialization format for returned values.',
+ enum: ['json', 'csv', 'txt', 'md', 'html'],
+ },
mimeType: {
type: 'string',
description: 'Optional MIME type override when inference is not enough.',
@@ -2702,7 +2926,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
path: {
type: 'string',
- description: 'Canonical destination VFS path, e.g. "files/Reports/clip.mp4".',
+ description: 'Canonical destination VFS path, e.g. "files/Reports/chart.png".',
+ },
+ sandboxPath: {
+ type: 'string',
+ description:
+ 'Optional full path to a file created inside the sandbox. Omit to save the code return value.',
},
},
required: ['path', 'mode'],
@@ -3028,6 +3257,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
},
resultSchema: undefined,
},
+ load_slide_layout: {
+ parameters: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ description:
+ "Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
+ },
+ },
+ required: ['name'],
+ },
+ resultSchema: undefined,
+ },
manage_credential: {
parameters: {
type: 'object',
@@ -5325,7 +5568,59 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }; select (enum) columns also take { options: [names], multiple?: true } — options is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: [names], multiple?: true }; reference requires referenceTableId.',
+ properties: {
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
+ multiple: {
+ type: 'boolean',
+ description:
+ 'Whether a select cell may hold several options (default false). Switching true → false fails if any row has more than one selected.',
+ },
+ name: {
+ type: 'string',
+ },
+ options: {
+ type: 'array',
+ description:
+ 'Choices for a select (enum) column as display names, e.g. ["Open", "Closed"]. Required when creating or converting to select. On update_column this REPLACES the whole list, matched BY NAME — send the full list including options you keep; omitting one deletes it and clears its cells. Max 100.',
+ items: {
+ type: 'string',
+ },
+ },
+ position: {
+ type: 'integer',
+ },
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
+ type: {
+ type: 'string',
+ description:
+ 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
+ },
+ unique: {
+ type: 'boolean',
+ description:
+ 'Set or clear the column unique constraint (update_column; not supported on select columns)',
+ },
+ },
+ required: ['name', 'type'],
},
columnName: {
type: 'string',
@@ -5337,6 +5632,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
description:
'Array of column names to delete at once (preferred for multi-column delete_column)',
},
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
multiple: {
type: 'boolean',
description:
@@ -5349,7 +5649,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
newType: {
type: 'string',
description:
- 'New column type for update_column: string, number, boolean, date, json, select. Converting to select also requires options; conversion fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.',
+ 'New column type for update_column: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if an existing cell value matches no option. A multiple select round-trips through text as a comma-separated cell.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
},
options: {
type: 'array',
@@ -5364,6 +5674,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
description:
'Write this call\'s result to a NEW workspace file instead of returning it (e.g. "files/export.csv"). On success the tool result is REPLACED by a file receipt (fileId, vfsPath, size) — set it only when the file IS the goal. ".csv" serializes rows as a CSV table; ".json"/".txt"/".md"/".html" write pretty-printed JSON of the full result envelope. Missing parent folders are created; an existing path fails.',
},
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
tableId: {
type: 'string',
description: 'Table ID (required for every operation)',
@@ -5555,7 +5870,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
schema: {
type: 'object',
description:
- 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; a select (enum) column also requires options (display names) and takes multiple?.',
+ 'Table schema with a columns array (required for create). Each column: { name, type, unique? }; currency takes currencyCode?, reference requires referenceTableId, and select requires options (display names) and takes multiple?.',
},
tableId: {
type: 'string',
@@ -5984,7 +6299,58 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
column: {
type: 'object',
description:
- 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Column definition for add_column: { name, type, unique?, position? }. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.',
+ properties: {
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
+ multiple: {
+ type: 'boolean',
+ description:
+ 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.',
+ },
+ name: {
+ type: 'string',
+ },
+ options: {
+ type: 'array',
+ description:
+ 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.',
+ items: {
+ type: 'string',
+ },
+ },
+ position: {
+ type: 'integer',
+ },
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
+ type: {
+ type: 'string',
+ description:
+ 'Column type for add_column: string, number, currency, boolean, date, json, select, or reference.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
+ },
+ unique: {
+ type: 'boolean',
+ description: 'Set column unique constraint (optional for update_column)',
+ },
+ },
+ required: ['name', 'type'],
},
columnName: {
type: 'string',
@@ -5996,6 +6362,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
description:
'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.',
},
+ currencyCode: {
+ type: 'string',
+ description:
+ 'Optional ISO 4217 currency code for a currency column, e.g. USD. Omit to use the table default.',
+ },
cursor: {
type: 'string',
description:
@@ -6140,7 +6511,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
newType: {
type: 'string',
description:
- 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
+ 'New column type (optional for update_column). Types: string, number, currency, boolean, date, json, select, reference. Converting to currency optionally takes currencyCode; converting to reference requires referenceTableId; converting to select requires options and fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.',
+ enum: [
+ 'string',
+ 'number',
+ 'currency',
+ 'boolean',
+ 'date',
+ 'json',
+ 'select',
+ 'reference',
+ ],
},
options: {
type: 'array',
@@ -6213,6 +6594,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
description:
'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.',
},
+ referenceTableId: {
+ type: 'string',
+ description:
+ 'Target table ID for a reference column. Required when creating or converting to reference; use the id from tables/{name}/meta.json, not the table name or path.',
+ },
rowId: {
type: 'string',
description:
@@ -6239,7 +6625,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = {
schema: {
type: 'object',
description:
- 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.',
+ 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. Types: string, number, currency, boolean, date, json, select, reference. Currency optionally takes currencyCode; select takes { options: ["Open", "Closed"], multiple?: true }; reference requires referenceTableId.',
},
scope: {
type: 'string',
diff --git a/apps/sim/lib/folders/bulk.test.ts b/apps/sim/lib/folders/bulk.test.ts
index 08bb9092973..4fc6c927e7d 100644
--- a/apps/sim/lib/folders/bulk.test.ts
+++ b/apps/sim/lib/folders/bulk.test.ts
@@ -46,6 +46,7 @@ describe('planFolderSelection', () => {
expect(result.selected).toEqual([{ id: 'a', name: 'A' }])
expect(result.contained).toEqual([])
expect([...result.covered].sort()).toEqual(['a', 'a1', 'a1x'])
+ expect([...(result.coveredBySelected.get('a') ?? [])].sort()).toEqual(['a', 'a1', 'a1x'])
})
it('reports an explicitly selected descendant as contained, not as a second selection', async () => {
diff --git a/apps/sim/lib/folders/bulk.ts b/apps/sim/lib/folders/bulk.ts
index 2e32c343132..3e09829cb5f 100644
--- a/apps/sim/lib/folders/bulk.ts
+++ b/apps/sim/lib/folders/bulk.ts
@@ -35,6 +35,8 @@ export interface FolderSelectionPlan {
* acted on a second time.
*/
covered: Set
+ /** The covered subtree for each top-level selected folder, used for per-folder preflight. */
+ coveredBySelected: Map>
}
/**
@@ -52,7 +54,13 @@ export async function planFolderSelection(
folderIds: readonly string[]
): Promise {
if (folderIds.length === 0) {
- return { selected: [], notFound: [], contained: [], covered: new Set() }
+ return {
+ selected: [],
+ notFound: [],
+ contained: [],
+ covered: new Set(),
+ coveredBySelected: new Map(),
+ }
}
const rows = await listActiveFolderRows(workspaceId, resourceType, {
@@ -64,6 +72,7 @@ export async function planFolderSelection(
const notFound: string[] = []
const contained: BulkFolderAffected[] = []
const covered = new Set()
+ const coveredBySelected = new Map>()
const requested = new Set()
for (const folderId of folderIds) {
@@ -111,8 +120,9 @@ export async function planFolderSelection(
}
if (covered.has(folderId)) continue
selected.push(entry)
- covered.add(folderId)
- for (const descendantId of descendantsOf.get(folderId) ?? []) covered.add(descendantId)
+ const selectedCoverage = new Set([folderId, ...(descendantsOf.get(folderId) ?? [])])
+ coveredBySelected.set(folderId, selectedCoverage)
+ for (const coveredId of selectedCoverage) covered.add(coveredId)
}
/**
@@ -126,7 +136,7 @@ export async function planFolderSelection(
for (const descendantId of descendantsOf.get(folder.id) ?? []) covered.add(descendantId)
}
- return { selected, notFound, contained, covered }
+ return { selected, notFound, contained, covered, coveredBySelected }
}
/**
diff --git a/apps/sim/lib/folders/cascade.test.ts b/apps/sim/lib/folders/cascade.test.ts
index 5c4af123168..7fd73951b7a 100644
--- a/apps/sim/lib/folders/cascade.test.ts
+++ b/apps/sim/lib/folders/cascade.test.ts
@@ -1,7 +1,13 @@
/**
* @vitest-environment node
*/
-import { flattenMockConditions, hasMockCondition } from '@sim/testing'
+import {
+ flattenMockConditions,
+ hasMockCondition,
+ queueTableRows,
+ resetDbChainMock,
+ schemaMock,
+} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
archiveFolderCascade,
@@ -540,3 +546,48 @@ describe('knowledge_base and table folder resources', () => {
expect(tableConfig.sortOrderColumn).toBeUndefined()
})
})
+
+describe('table folder deletion guard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ })
+
+ it('refuses the whole folder before a referenced table can be archived', async () => {
+ queueTableRows(schemaMock.userTableDefinitions, [])
+ queueTableRows(schemaMock.userTableDefinitions, [
+ {
+ id: 'tbl_customers',
+ name: 'Customers',
+ folderId: 'folder-child',
+ schema: { columns: [{ id: 'name', name: 'Name', type: 'string' }] },
+ },
+ {
+ id: 'tbl_orders',
+ name: 'Orders',
+ folderId: null,
+ schema: {
+ columns: [
+ {
+ id: 'customer',
+ name: 'Customer',
+ type: 'reference',
+ referenceTableId: 'tbl_customers',
+ },
+ ],
+ },
+ },
+ ])
+
+ await expect(
+ FOLDER_RESOURCES.table.guardDelete?.({
+ workspaceId: 'ws-1',
+ folderIds: ['folder-root', 'folder-child'],
+ })
+ ).resolves.toEqual({
+ error:
+ 'Cannot delete table "Customers" because it is referenced by table "Orders". Remove the reference column first.',
+ errorCode: 'conflict',
+ })
+ })
+})
diff --git a/apps/sim/lib/folders/config.ts b/apps/sim/lib/folders/config.ts
index cca02a55313..5d61112c632 100644
--- a/apps/sim/lib/folders/config.ts
+++ b/apps/sim/lib/folders/config.ts
@@ -305,8 +305,9 @@ async function restoreKnowledgeBaseChildren(context: CascadeChildrenContext): Pr
/**
* Archives the tables in a folder subtree through the canonical table delete, so the
- * `deleteLocked` guard in its WHERE clause still applies. {@link guardLockedTables} has
- * already refused the whole folder if any table is locked, so this should not encounter one.
+ * `deleteLocked` and inbound-reference guards still apply. {@link guardTableDeletion} has
+ * already refused the whole folder if any table cannot be deleted, so this should not
+ * encounter a partial cascade.
*/
async function archiveTableChildren(context: CascadeChildrenContext): Promise {
const { deleteTable } = await import('@/lib/table/service')
@@ -345,23 +346,27 @@ async function restoreTableChildren(context: CascadeChildrenContext): Promise {
- const [{ db }, { and, eq: eqOp, inArray, isNull }] = await Promise.all([
+ const [
+ { db },
+ { and, eq: eqOp, inArray, isNull },
+ { findActiveTableReferenceBlockers, tableReferenceBlockerMessage },
+ ] = await Promise.all([
import('@sim/db'),
import('drizzle-orm'),
+ import('@/lib/table/column-types/registry.server'),
])
const locked = await db
@@ -376,12 +381,22 @@ async function guardLockedTables({
)
)
- if (locked.length === 0) return null
+ if (locked.length > 0) {
+ const names = locked.map((row) => row.name).join(', ')
+ return {
+ error: `Cannot delete folder: ${locked.length === 1 ? 'table' : 'tables'} ${names} ${locked.length === 1 ? 'is' : 'are'} delete-locked`,
+ errorCode: 'locked',
+ }
+ }
+
+ const [blocker] = await findActiveTableReferenceBlockers(db, workspaceId, {
+ folderIds: new Set(folderIds),
+ })
+ if (!blocker) return null
- const names = locked.map((row) => row.name).join(', ')
return {
- error: `Cannot delete folder: ${locked.length === 1 ? 'table' : 'tables'} ${names} ${locked.length === 1 ? 'is' : 'are'} delete-locked`,
- errorCode: 'locked',
+ error: tableReferenceBlockerMessage(blocker.targetTableName, [blocker.referencingTableName]),
+ errorCode: 'conflict',
}
}
@@ -515,7 +530,7 @@ export const FOLDER_RESOURCES: Record
>,
archiveChildren: archiveTableChildren,
restoreChildren: restoreTableChildren,
- guardDelete: guardLockedTables,
+ guardDelete: guardTableDeletion,
},
}
diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts
index 0bafc8bfce9..690bda6ceb1 100644
--- a/apps/sim/lib/table/application/bulk.test.ts
+++ b/apps/sim/lib/table/application/bulk.test.ts
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
resolveWorkspaceContext: vi.fn(),
signal: vi.fn(),
notifyTables: vi.fn(),
+ findReferenceBlockers: vi.fn(),
resolveFolderPathFromIndex: vi.fn(),
resolveTableFolderPath: vi.fn(),
}))
@@ -76,6 +77,11 @@ vi.mock('@/lib/table/application/context', () => ({
resolveTableWorkspaceContext: mocks.resolveWorkspaceContext,
}))
vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal }))
+vi.mock('@/lib/table/column-types/registry.server', () => ({
+ findActiveTableReferenceBlockers: mocks.findReferenceBlockers,
+ tableReferenceBlockerMessage: (target: string, blockers: string[]) =>
+ `Cannot delete table "${target}" because it is referenced by table "${blockers[0]}". Remove the reference column first.`,
+}))
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { bulkDeleteTables, bulkMoveTables } from '@/lib/table/application/bulk'
@@ -118,6 +124,7 @@ describe('table bulk application use cases', () => {
mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext)
mocks.resolvePermission.mockResolvedValue('write')
mocks.planFolderSelection.mockResolvedValue(emptyPlan)
+ mocks.findReferenceBlockers.mockResolvedValue([])
mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' })
mocks.resolveTableContext.mockImplementation(async (tableId: string) => tableContext(tableId))
mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' })
@@ -262,6 +269,82 @@ describe('table bulk application use cases', () => {
expect(result.deleted).toEqual([{ kind: 'table', id: 'table-2', name: 'Archived' }])
})
+ it('does not delete a referenced target even when its referring table is selected first', async () => {
+ mocks.findReferenceBlockers.mockResolvedValueOnce([
+ {
+ targetTableId: 'customers',
+ targetTableName: 'Customers',
+ targetFolderId: null,
+ referencingTableId: 'orders',
+ referencingTableName: 'Orders',
+ },
+ ])
+
+ const result = await bulkDeleteTables.execute({
+ principal,
+ input: {
+ assertedWorkspaceId: 'workspace-1',
+ tableIds: ['orders', 'customers'],
+ folderKeying: 'ids' as const,
+ folders: [],
+ },
+ })
+
+ expect(result.deleted).toEqual([{ kind: 'table', id: 'orders', name: 'Archived' }])
+ expect(result.failed).toEqual([
+ {
+ kind: 'table',
+ id: 'customers',
+ name: 'Table customers',
+ reason:
+ 'Cannot delete table "Table customers" because it is referenced by table "Orders". Remove the reference column first.',
+ },
+ ])
+ expect(mocks.deleteTable).toHaveBeenCalledTimes(1)
+ expect(mocks.deleteTable).toHaveBeenCalledWith('orders', 'request-1', expect.anything())
+ })
+
+ it('blocks a selected folder when it contains a referenced table', async () => {
+ mocks.planFolderSelection.mockResolvedValueOnce({
+ selected: [{ id: 'folder-1', name: 'Sales' }],
+ notFound: [],
+ contained: [],
+ covered: new Set(['folder-1', 'folder-child']),
+ coveredBySelected: new Map([['folder-1', new Set(['folder-1', 'folder-child'])]]),
+ })
+ mocks.findReferenceBlockers.mockResolvedValueOnce([
+ {
+ targetTableId: 'customers',
+ targetTableName: 'Customers',
+ targetFolderId: 'folder-child',
+ referencingTableId: 'orders',
+ referencingTableName: 'Orders',
+ },
+ ])
+
+ const result = await bulkDeleteTables.execute({
+ principal,
+ input: {
+ assertedWorkspaceId: 'workspace-1',
+ tableIds: [],
+ folderKeying: 'ids' as const,
+ folders: ['folder-1'],
+ },
+ })
+
+ expect(result.deleted).toEqual([])
+ expect(result.failed).toEqual([
+ {
+ kind: 'folder',
+ id: 'folder-1',
+ name: 'Sales',
+ reason:
+ 'Cannot delete table "Customers" because it is referenced by table "Orders". Remove the reference column first.',
+ },
+ ])
+ expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled()
+ })
+
it('conceals an inaccessible table as not-found rather than naming it', async () => {
mocks.resolveTableContext.mockRejectedValueOnce(
new OrchestrationError('not_found', 'Table not found')
@@ -520,6 +603,7 @@ describe('path-keyed bulk table selections', () => {
mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext)
mocks.resolvePermission.mockResolvedValue('write')
mocks.planFolderSelection.mockResolvedValue(emptyPlan)
+ mocks.findReferenceBlockers.mockResolvedValue([])
mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' })
mocks.resolveTableContext.mockImplementation(async (tableId: string) => tableContext(tableId))
mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' })
diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts
index c369c6bf7d8..b5c011d7b8b 100644
--- a/apps/sim/lib/table/application/bulk.ts
+++ b/apps/sim/lib/table/application/bulk.ts
@@ -1,5 +1,6 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { resolvePrincipalAttribution } from '@sim/auth/principal'
+import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items'
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -32,6 +33,10 @@ import {
} from '@/lib/table/application/context'
import { resolveTableFolderPath } from '@/lib/table/application/folder-paths'
import { tableOperations } from '@/lib/table/application/operations'
+import {
+ findActiveTableReferenceBlockers,
+ tableReferenceBlockerMessage,
+} from '@/lib/table/column-types/registry.server'
import { signalTableSchemaChanged } from '@/lib/table/events'
import { TableLockedError } from '@/lib/table/mutation-locks'
@@ -489,6 +494,26 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({
TABLE_FOLDER_RESOURCE_TYPE,
context.folderIds
)
+ const referenceBlockers = await findActiveTableReferenceBlockers(db, context.workspaceId, {
+ tableIds: context.tableIds,
+ folderIds: plan.covered,
+ })
+ const blockersByTargetTableId = new Map()
+ for (const blocker of referenceBlockers) {
+ const targetBlockers = blockersByTargetTableId.get(blocker.targetTableId) ?? []
+ targetBlockers.push(blocker)
+ blockersByTargetTableId.set(blocker.targetTableId, targetBlockers)
+ }
+ const blockedFolderIds = new Map(
+ plan.selected.flatMap((folder) => {
+ const coveredFolderIds = plan.coveredBySelected?.get(folder.id) ?? new Set([folder.id])
+ const blocker = referenceBlockers.find(
+ (candidate) =>
+ candidate.targetFolderId !== null && coveredFolderIds.has(candidate.targetFolderId)
+ )
+ return blocker ? [[folder.id, blocker] as const] : []
+ })
+ )
const deleted: BulkTableItem[] = []
const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] }
@@ -501,6 +526,16 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({
plan.covered,
(canonical) => authorizeTableOperation(principal, tableOperations.bulkDelete, canonical),
async (canonical) => {
+ const blockers = blockersByTargetTableId.get(canonical.table.id)
+ if (blockers && blockers.length > 0) {
+ throw new OrchestrationError(
+ 'conflict',
+ tableReferenceBlockerMessage(
+ canonical.table.name,
+ blockers.map((blocker) => blocker.referencingTableName)
+ )
+ )
+ }
const { archived } = await deleteTable(canonical.table.id, generateRequestId(), {
expectedWorkspaceId: context.workspaceId,
skipNotify: true,
@@ -514,19 +549,33 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({
const deletedItems = { tables: deleted.length, folders: 0 }
if (terminalError === undefined && plan.selected.length > 0) {
- const folders = await bulkDeleteFolders({
- workspaceId: context.workspaceId,
- resourceType: TABLE_FOLDER_RESOURCE_TYPE,
- userId: resolvePrincipalAttribution(principal, {
- workspaceBillingOwnerUserId: context.billedAccountUserId,
- }).attributedUserId,
- folders: plan.selected,
- countKey: 'tables',
+ const deletableFolders = plan.selected.filter((folder) => {
+ const blocker = blockedFolderIds.get(folder.id)
+ if (!blocker) return true
+ outcome.failed.push({
+ kind: 'folder',
+ ...folder,
+ reason: tableReferenceBlockerMessage(blocker.targetTableName, [
+ blocker.referencingTableName,
+ ]),
+ })
+ return false
})
- for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder })
- for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder })
- deletedItems.folders = folders.folderCount
- deletedItems.tables += folders.resourceCount
+ if (deletableFolders.length > 0) {
+ const folders = await bulkDeleteFolders({
+ workspaceId: context.workspaceId,
+ resourceType: TABLE_FOLDER_RESOURCE_TYPE,
+ userId: resolvePrincipalAttribution(principal, {
+ workspaceBillingOwnerUserId: context.billedAccountUserId,
+ }).attributedUserId,
+ folders: deletableFolders,
+ countKey: 'tables',
+ })
+ for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder })
+ for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder })
+ deletedItems.folders = folders.folderCount
+ deletedItems.tables += folders.resourceCount
+ }
}
logger.info('Bulk archived tables and folders', {
diff --git a/apps/sim/lib/table/column-types/registry.server.test.ts b/apps/sim/lib/table/column-types/registry.server.test.ts
index 14f64905dd9..0cf22a3b0bd 100644
--- a/apps/sim/lib/table/column-types/registry.server.test.ts
+++ b/apps/sim/lib/table/column-types/registry.server.test.ts
@@ -4,17 +4,24 @@
import { hasMockCondition, schemaMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
-import { assertColumnReferencesInWorkspace } from '@/lib/table/column-types/registry.server'
+import type { DbOrTx } from '@/lib/db/types'
+import {
+ assertColumnReferencesInWorkspace,
+ findActiveTableReferenceBlockers,
+ tableReferenceBlockerMessage,
+} from '@/lib/table/column-types/registry.server'
import type { DbTransaction } from '@/lib/table/planner'
function transactionWithTargets(targetIds: string[]) {
- const where = vi.fn().mockResolvedValue(targetIds.map((id) => ({ id })))
+ const lock = vi.fn().mockResolvedValue(targetIds.map((id) => ({ id })))
+ const where = vi.fn(() => ({ for: lock }))
const from = vi.fn(() => ({ where }))
const select = vi.fn(() => ({ from }))
return {
trx: { select } as unknown as DbTransaction,
select,
where,
+ lock,
}
}
@@ -30,7 +37,7 @@ describe('assertColumnReferencesInWorkspace', () => {
})
it('accepts active Reference targets returned for the workspace', async () => {
- const { trx, select, where } = transactionWithTargets(['tbl_accounts', 'tbl_companies'])
+ const { trx, select, where, lock } = transactionWithTargets(['tbl_accounts', 'tbl_companies'])
await assertColumnReferencesInWorkspace(trx, 'ws_1', [
{
@@ -68,6 +75,7 @@ describe('assertColumnReferencesInWorkspace', () => {
node.values.length === 2
)
).toBe(true)
+ expect(lock).toHaveBeenCalledWith('key share')
expect(
hasMockCondition(
condition,
@@ -101,3 +109,68 @@ describe('assertColumnReferencesInWorkspace', () => {
})
})
})
+
+describe('findActiveTableReferenceBlockers', () => {
+ const activeTables = [
+ {
+ id: 'tbl_customers',
+ name: 'Customers',
+ folderId: 'folder_sales',
+ schema: { columns: [{ id: 'name', name: 'Name', type: 'string' }] },
+ },
+ {
+ id: 'tbl_orders',
+ name: 'Orders',
+ folderId: null,
+ schema: {
+ columns: [
+ {
+ id: 'customer',
+ name: 'Customer',
+ type: 'reference',
+ referenceTableId: 'tbl_customers',
+ },
+ ],
+ },
+ },
+ ]
+
+ function executorWithTables() {
+ const where = vi.fn().mockResolvedValue(activeTables)
+ const from = vi.fn(() => ({ where }))
+ return { select: vi.fn(() => ({ from })) } as unknown as DbOrTx
+ }
+
+ it('names the referring table for a selected target table', async () => {
+ await expect(
+ findActiveTableReferenceBlockers(executorWithTables(), 'ws_1', {
+ tableIds: ['tbl_customers'],
+ })
+ ).resolves.toEqual([
+ {
+ targetTableId: 'tbl_customers',
+ targetTableName: 'Customers',
+ targetFolderId: 'folder_sales',
+ referencingTableId: 'tbl_orders',
+ referencingTableName: 'Orders',
+ },
+ ])
+ })
+
+ it('finds referenced targets anywhere in a selected folder subtree', async () => {
+ const blockers = await findActiveTableReferenceBlockers(executorWithTables(), 'ws_1', {
+ folderIds: new Set(['folder_sales']),
+ })
+
+ expect(blockers).toHaveLength(1)
+ expect(blockers[0]?.targetTableName).toBe('Customers')
+ })
+})
+
+describe('tableReferenceBlockerMessage', () => {
+ it('shows the target and every table preventing deletion', () => {
+ expect(tableReferenceBlockerMessage('Customers', ['Orders', 'Invoices'])).toBe(
+ 'Cannot delete table "Customers" because it is referenced by tables "Invoices", "Orders". Remove the reference columns first.'
+ )
+ })
+})
diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts
index 44fbde09b2f..01f7d2c1a6c 100644
--- a/apps/sim/lib/table/column-types/registry.server.ts
+++ b/apps/sim/lib/table/column-types/registry.server.ts
@@ -14,6 +14,7 @@
import { userTableDefinitions, userTableRows } from '@sim/db/schema'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import { OrchestrationError } from '@/lib/core/orchestration/types'
+import type { DbOrTx } from '@/lib/db/types'
import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types/registry'
import type { ColumnType } from '@/lib/table/column-types/types'
import type {
@@ -22,7 +23,7 @@ import type {
} from '@/lib/table/column-types/types.server'
import type { DbTransaction } from '@/lib/table/planner'
import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance'
-import type { ColumnDefinition, JsonValue, SelectOption } from '@/lib/table/types'
+import type { ColumnDefinition, JsonValue, SelectOption, TableSchema } from '@/lib/table/types'
/**
* Rewrites a column's cells from stored option **ids** to option **names**, for
@@ -294,9 +295,126 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record
typeof column.referenceTableId === 'string' ? [column.referenceTableId] : [],
+ remapReferencedTableIds: (column, tableIdMap) => {
+ const referenceTableId = column.referenceTableId
+ if (typeof referenceTableId !== 'string') return column
+ const remappedTableId = tableIdMap.get(referenceTableId)
+ return remappedTableId && remappedTableId !== referenceTableId
+ ? { ...column, referenceTableId: remappedTableId }
+ : column
+ },
},
}
+/** Collects the distinct table IDs named by type-specific column metadata. */
+export function collectColumnReferencedTableIds(columns: readonly ColumnDefinition[]): string[] {
+ return [
+ ...new Set(
+ columns.flatMap(
+ (column) => COLUMN_TYPE_SERVER_REGISTRY[column.type].referencedTableIds?.(column) ?? []
+ )
+ ),
+ ]
+}
+
+export interface ActiveTableReferenceBlocker {
+ targetTableId: string
+ targetTableName: string
+ targetFolderId: string | null
+ referencingTableId: string
+ referencingTableName: string
+}
+
+/** Builds the caller-facing conflict shown for a referenced table deletion. */
+export function tableReferenceBlockerMessage(
+ targetTableName: string,
+ referencingTableNames: readonly string[]
+): string {
+ const names = [...new Set(referencingTableNames)].sort().map((name) => `"${name}"`)
+ const blockerLabel = names.length === 1 ? 'table' : 'tables'
+ const columnLabel = names.length === 1 ? 'column' : 'columns'
+ return `Cannot delete table "${targetTableName}" because it is referenced by ${blockerLabel} ${names.join(', ')}. Remove the reference ${columnLabel} first.`
+}
+
+/**
+ * Finds active tables that point at any active table in the requested deletion selection.
+ *
+ * The table service caps the number of tables in a workspace, so reading the active definitions
+ * once is bounded and cheaper than issuing one JSONB search per table in a folder cascade.
+ * Reference ownership still comes from the server column-type registry; this function does not
+ * duplicate knowledge of the `reference` column shape.
+ */
+export async function findActiveTableReferenceBlockers(
+ executor: DbOrTx,
+ workspaceId: string,
+ selection: { tableIds?: readonly string[]; folderIds?: ReadonlySet }
+): Promise {
+ const selectedTableIds = new Set(selection.tableIds)
+ const selectedFolderIds = selection.folderIds ?? new Set()
+ if (selectedTableIds.size === 0 && selectedFolderIds.size === 0) return []
+
+ const activeTables = await executor
+ .select({
+ id: userTableDefinitions.id,
+ name: userTableDefinitions.name,
+ folderId: userTableDefinitions.folderId,
+ schema: userTableDefinitions.schema,
+ })
+ .from(userTableDefinitions)
+ .where(
+ and(
+ eq(userTableDefinitions.workspaceId, workspaceId),
+ isNull(userTableDefinitions.archivedAt)
+ )
+ )
+
+ const selectedTargets = new Map(
+ activeTables
+ .filter(
+ (table) =>
+ selectedTableIds.has(table.id) ||
+ (table.folderId !== null && selectedFolderIds.has(table.folderId))
+ )
+ .map((table) => [table.id, table])
+ )
+ if (selectedTargets.size === 0) return []
+
+ const blockers: ActiveTableReferenceBlocker[] = []
+ for (const referencingTable of activeTables) {
+ for (const referencedTableId of collectColumnReferencedTableIds(
+ (referencingTable.schema as TableSchema).columns
+ )) {
+ const target = selectedTargets.get(referencedTableId)
+ if (!target) continue
+ blockers.push({
+ targetTableId: target.id,
+ targetTableName: target.name,
+ targetFolderId: target.folderId,
+ referencingTableId: referencingTable.id,
+ referencingTableName: referencingTable.name,
+ })
+ }
+ }
+
+ return blockers.sort(
+ (left, right) =>
+ left.targetTableName.localeCompare(right.targetTableName) ||
+ left.referencingTableName.localeCompare(right.referencingTableName)
+ )
+}
+
+/** Rewrites every table reference owned by a registered column type. */
+export function remapColumnReferencedTableIds(
+ columns: readonly ColumnDefinition[],
+ tableIdMap: ReadonlyMap
+): ColumnDefinition[] {
+ return columns.map(
+ (column) =>
+ COLUMN_TYPE_SERVER_REGISTRY[column.type].remapReferencedTableIds?.(column, tableIdMap) ??
+ column
+ )
+}
+
/**
* Validates every table ID referenced by column metadata in one query.
*
@@ -308,13 +426,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
@@ -327,6 +439,7 @@ export async function assertColumnReferencesInWorkspace(
isNull(userTableDefinitions.archivedAt)
)
)
+ .for('key share')
const foundIds = new Set(targets.map((target) => target.id))
const missingId = referencedTableIds.find((id) => !foundIds.has(id))
if (missingId) {
diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts
index b569c73a0ea..6bc42ad2377 100644
--- a/apps/sim/lib/table/column-types/types.server.ts
+++ b/apps/sim/lib/table/column-types/types.server.ts
@@ -37,6 +37,14 @@ export interface ColumnTypeServerDefinition {
* a schema is persisted. Omitted by types that do not reference tables.
*/
readonly referencedTableIds?: (column: ColumnDefinition) => readonly string[]
+ /**
+ * Rewrites this column's table references through a source-to-target identity map.
+ * Omitted by types that do not reference tables.
+ */
+ readonly remapReferencedTableIds?: (
+ column: ColumnDefinition,
+ tableIdMap: ReadonlyMap
+ ) => ColumnDefinition
/**
* Rewrites cells into this type's canonical storage shape when a column is
* converted **to** it. Omitted when the stored bytes are already correct.
diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts
index 0085ce82229..cc1da57bb17 100644
--- a/apps/sim/lib/table/service.test.ts
+++ b/apps/sim/lib/table/service.test.ts
@@ -14,10 +14,17 @@ import type { TableSchema } from '@/lib/table/types'
const mocks = vi.hoisted(() => ({
assertColumnReferencesInWorkspace: vi.fn(),
+ findActiveTableReferenceBlockers: vi.fn(),
+ tableReferenceBlockerMessage: vi.fn(
+ (target: string, blockers: string[]) =>
+ `Cannot delete table "${target}" because it is referenced by table "${blockers[0]}". Remove the reference column first.`
+ ),
}))
vi.mock('@/lib/table/column-types/registry.server', () => ({
assertColumnReferencesInWorkspace: mocks.assertColumnReferencesInWorkspace,
+ findActiveTableReferenceBlockers: mocks.findActiveTableReferenceBlockers,
+ tableReferenceBlockerMessage: mocks.tableReferenceBlockerMessage,
}))
vi.mock('@/lib/realtime/notify', () => ({
@@ -29,7 +36,7 @@ vi.mock('@/lib/table/billing', () => ({
notifyTableRowUsage: vi.fn(),
}))
-import { createTable, getTableById } from '@/lib/table/service'
+import { createTable, deleteTable, getTableById, TableReferencedError } from '@/lib/table/service'
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
@@ -331,3 +338,71 @@ describe('getTableById job derivation', () => {
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})
})
+
+describe('deleteTable reference guard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mocks.findActiveTableReferenceBlockers.mockResolvedValue([])
+ })
+
+ const activeTable = {
+ name: 'Customers',
+ archivedAt: null,
+ deleteLocked: false,
+ workspaceId: WORKSPACE_ID,
+ }
+
+ it('archives an unreferenced table inside the guarded transaction', async () => {
+ queueTableRows(schemaMock.userTableDefinitions, [activeTable])
+ dbChainMockFns.returning.mockResolvedValueOnce([
+ { name: 'Customers', workspaceId: WORKSPACE_ID },
+ ])
+
+ await expect(deleteTable('tbl_customers', 'request-1')).resolves.toEqual({
+ archived: { name: 'Customers', workspaceId: WORKSPACE_ID },
+ })
+
+ expect(dbChainMockFns.for).toHaveBeenCalledWith('update')
+ expect(mocks.findActiveTableReferenceBlockers).toHaveBeenCalledWith(
+ expect.anything(),
+ WORKSPACE_ID,
+ { tableIds: ['tbl_customers'] }
+ )
+ expect(dbChainMockFns.update).toHaveBeenCalledOnce()
+ })
+
+ it('blocks deletion and names the table holding the reference', async () => {
+ queueTableRows(schemaMock.userTableDefinitions, [activeTable])
+ mocks.findActiveTableReferenceBlockers.mockResolvedValueOnce([
+ {
+ targetTableId: 'tbl_customers',
+ targetTableName: 'Customers',
+ targetFolderId: null,
+ referencingTableId: 'tbl_orders',
+ referencingTableName: 'Orders',
+ },
+ ])
+
+ await expect(deleteTable('tbl_customers', 'request-1')).rejects.toEqual(
+ expect.objectContaining({
+ name: 'TableReferencedError',
+ code: 'conflict',
+ message:
+ 'Cannot delete table "Customers" because it is referenced by table "Orders". Remove the reference column first.',
+ })
+ )
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ expect(TableReferencedError).toBeTypeOf('function')
+ })
+
+ it('keeps the existing delete-lock verdict ahead of the reference check', async () => {
+ queueTableRows(schemaMock.userTableDefinitions, [{ ...activeTable, deleteLocked: true }])
+
+ await expect(deleteTable('tbl_customers', 'request-1')).rejects.toMatchObject({
+ name: 'TableLockedError',
+ lock: 'delete',
+ })
+ expect(mocks.findActiveTableReferenceBlockers).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts
index d07969be06d..1165c3e80ba 100644
--- a/apps/sim/lib/table/service.ts
+++ b/apps/sim/lib/table/service.ts
@@ -36,7 +36,11 @@ import { resolveRestoredFolderId } from '@/lib/folders/queries'
import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify'
import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing'
import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys'
-import { assertColumnReferencesInWorkspace } from '@/lib/table/column-types/registry.server'
+import {
+ assertColumnReferencesInWorkspace,
+ findActiveTableReferenceBlockers,
+ tableReferenceBlockerMessage,
+} from '@/lib/table/column-types/registry.server'
import {
COLUMN_TYPES,
DEFAULT_TABLE_VIEW_NAME,
@@ -84,6 +88,14 @@ export class TableConflictError extends OrchestrationError {
}
}
+/** A table still has one or more active inbound reference columns. */
+export class TableReferencedError extends OrchestrationError {
+ constructor(targetTableName: string, referencingTableNames: readonly string[]) {
+ super('conflict', tableReferenceBlockerMessage(targetTableName, referencingTableNames))
+ this.name = 'TableReferencedError'
+ }
+}
+
export type TableScope = 'active' | 'archived' | 'all'
/**
@@ -1141,32 +1153,11 @@ export async function deleteTable(
options?: { archivedAt?: Date; skipNotify?: boolean; expectedWorkspaceId?: string }
): Promise<{ archived: { name: string; workspaceId: string | null } | null }> {
const now = options?.archivedAt ?? new Date()
- // Archiving destroys access to every row, so it is gated on the delete lock.
- // The guard is inline in the WHERE (atomic — no separate read, no TOCTOU);
- // a zero-row result is then disambiguated below (locked vs already-archived).
- const result = await db
- .update(userTableDefinitions)
- .set({ archivedAt: now, updatedAt: now })
- .where(
- and(
- eq(userTableDefinitions.id, tableId),
- options?.expectedWorkspaceId
- ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId)
- : undefined,
- isNull(userTableDefinitions.archivedAt),
- eq(userTableDefinitions.deleteLocked, false)
- )
- )
- .returning({
- createdBy: userTableDefinitions.createdBy,
- workspaceId: userTableDefinitions.workspaceId,
- name: userTableDefinitions.name,
- })
-
- const deleted = result[0]
- if (!deleted) {
- const [existing] = await db
+ const deleted = await db.transaction(async (trx) => {
+ await setTableTxTimeouts(trx)
+ const [existing] = await trx
.select({
+ name: userTableDefinitions.name,
archivedAt: userTableDefinitions.archivedAt,
deleteLocked: userTableDefinitions.deleteLocked,
workspaceId: userTableDefinitions.workspaceId,
@@ -1180,8 +1171,11 @@ export async function deleteTable(
: undefined
)
)
+ .for('update')
.limit(1)
- if (existing && !existing.archivedAt && existing.deleteLocked) {
+
+ if (!existing || existing.archivedAt) return null
+ if (existing.deleteLocked) {
logger.warn('Table mutation blocked by lock', {
tableId,
workspaceId: existing.workspaceId,
@@ -1189,8 +1183,36 @@ export async function deleteTable(
})
throw new TableLockedError('delete')
}
- // Otherwise the table is missing or already archived — a silent no-op, as before.
- }
+
+ const blockers = existing.workspaceId
+ ? await findActiveTableReferenceBlockers(trx, existing.workspaceId, {
+ tableIds: [tableId],
+ })
+ : []
+ if (blockers.length > 0) {
+ throw new TableReferencedError(
+ existing.name,
+ blockers.map((blocker) => blocker.referencingTableName)
+ )
+ }
+
+ const [archived] = await trx
+ .update(userTableDefinitions)
+ .set({ archivedAt: now, updatedAt: now })
+ .where(
+ and(
+ eq(userTableDefinitions.id, tableId),
+ isNull(userTableDefinitions.archivedAt),
+ eq(userTableDefinitions.deleteLocked, false)
+ )
+ )
+ .returning({
+ workspaceId: userTableDefinitions.workspaceId,
+ name: userTableDefinitions.name,
+ })
+ return archived ?? null
+ })
+
logger.info(`[${requestId}] Archived table ${tableId}`)
// Live tables list: only on a genuine archive (a no-op/already-archived delete changes nothing).
// Skipped under a folder cascade — deleteFolder fires one folder-level notify for the whole subtree,
From 5bb480f23803f0034eb4c035def3dc51b52fc54d Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Fri, 28 Aug 2026 20:17:07 -0700
Subject: [PATCH 12/12] docs(tables): add a gentle table tour
---
docs/explainers/sim-tables-gentle-tour.html | 1006 +++++++++++++++++++
1 file changed, 1006 insertions(+)
create mode 100644 docs/explainers/sim-tables-gentle-tour.html
diff --git a/docs/explainers/sim-tables-gentle-tour.html b/docs/explainers/sim-tables-gentle-tour.html
new file mode 100644
index 00000000000..164e9b5911d
--- /dev/null
+++ b/docs/explainers/sim-tables-gentle-tour.html
@@ -0,0 +1,1006 @@
+
+
+
+
+
+ Sim Tables: a gentle tour from grid to JSON
+
+
+
+
+
+ A codebase field guide
+ Sim Tables: a gentle tour from grid to JSON
+ You are looking at a hybrid system: it feels like a spreadsheet, is protected and queried like a database, and stores each row's changing cells as a JSON object. This guide builds that mental model first, then shows you exactly where to look in the repository.
+
+
+ - Date
- 2026-08-25
+ - Input shape
- concept
+ - Subject
- How the Sim tables module stores data and moves it through the application
+
+
+
+ It is a real database.Postgres holds the table definitions, rows, views, jobs, and execution state.
+ It is not one SQL table per user table.All Sim tables share a small set of Postgres tables.
+ Cells are flexible JSON.One row's cells live together in a queryable JSONB object, keyed by stable column IDs.
+
+
+
+
+ The whole system in one picture
+ When you edit a cell, the request passes through a few distinct layers. Each layer has one job: render, transport, authorize, apply rules, or store.
+
+
+
+
+
+ The most important correction to your starting idea is this: the module does use a database. The unusual part is that it does not create a brand-new SQL table every time a user clicks “New table.” Instead, every user-created table is represented inside shared Postgres records.
+
+
+
+ Who calls these endpoints, and what is a “use case”?
+ The short answer: an endpoint is a door into Sim. A use case is the meaningful action performed after someone comes through that door. “Use case” is an architecture term used by this repository—it is not special TypeScript syntax.
+
+
+ Your active file is a public API door. app/api/v2/tables/[tableId]/rows/[rowId]/route.ts is primarily called by an outside program—a customer script, server, command-line request, or SDK—using a Sim API key in the x-api-key header. The first-party table grid normally calls /api/table/... instead, using the logged-in browser session.
+
+
+
+
+
+
+
+
+ When you edit the grid
+ The browser takes the internal door
+ TableGrid calls useUpdateTableRow. That hook sends PATCH /api/table/[tableId]/rows/[rowId]. The route recognizes the logged-in session and hands the request to updateTableRow.
+
+
+ When code uses the API
+ An outside program takes the v2 door
+ A script or server sends PATCH /api/v2/tables/[tableId]/rows/[rowId] with an x-api-key. The v2 route checks that key and then hands the request to the same updateTableRow action.
+
+
+
+ Read the active route as a wiring diagram
+
+ export const PATCH = defineV2JsonRoute({
+ contract: v2UpdateTableRowContract,
+ auth: v2ApiKeyAuth,
+ mapInput: ({ params, body }) => ({
+ tableId: params.tableId,
+ data: body.data,
+ ...
+ }),
+ useCase: updateTableRow,
+ present: (result) => ({
+ data: rowDataToExternal(...)
+ }),
+})
+
+ contract What URL pieces and JSON body are allowed.
+ auth Who is calling? For v2, check the API key.
+ mapInput Turn the public request into the work order the application understands.
+ useCase Which meaningful application action should run.
+ present Turn the result into friendly public-API JSON.
+
+
+
+ useCase: updateTableRow does not mean that line immediately calls the function. It gives the reusable action to defineV2JsonRoute. When a real PATCH request arrives, the route builder authenticates and validates it, then calls updateTableRow.execute(...).
+
+
+ | Part of your file | Plain-English meaning |
+
+ GET | An API client asks, “Give me this row.” The route runs readTableRow. |
+ PATCH | An API client asks, “Change part of this row.” The route runs updateTableRow. |
+ DELETE | An API client asks, “Delete this row.” The route runs deleteTableRow. |
+
+
+
+
+ Endpoint / routeThe front desk. It knows HTTP, authentication, request shape, and response shape.
+ Use caseThe named, authorized product action: “read a row,” “update a row,” or “delete a row.” It can be reused behind more than one front desk.
+ ServiceThe specialist called by the use case to do lower-level table work, such as updating the row record in Postgres.
+
+
+
+ The mental shortcut: a route answers “How did this caller enter?” A use case answers “What are they allowed to do?” A service answers “How do we perform the low-level work?”
+
+
+
+
+ A concrete example: a tiny Companies table
+ Imagine the grid shows three columns—Company, Website, and Score—and one row. The friendly labels are for people; stable IDs are for storage.
+
+
+
+ Definition record
+ The table's schema
+ This says which columns exist and how each value should behave.
+ {
+ "name": "Companies",
+ "schema": {
+ "columns": [
+ { "id": "col_a", "name": "Company", "type": "string" },
+ { "id": "col_b", "name": "Website", "type": "string" },
+ { "id": "col_c", "name": "Score", "type": "number" }
+ ]
+ }
+}
+
+
+
+ Row record
+ The row's cell data
+ This is the flexible JSONB object stored in user_table_rows.data.
+ {
+ "id": "row_123",
+ "tableId": "tbl_456",
+ "data": {
+ "col_a": "Acme",
+ "col_b": "acme.test",
+ "col_c": 93
+ }
+}
+
+
+
+
+ Missing keys are normal. If you add a new “Industry” column, old rows do not need to be rewritten immediately. A row without that column's ID simply displays an empty cell.
+
+
+
+ // packages/db/schema.ts
+schema: jsonb('schema').notNull()
+
+// The same file, on user_table_rows
+data: jsonb('data').notNull()
+
+// apps/sim/lib/table/types.ts
+export type RowData = Record<string, JsonValue>
+
+ JSONB is Postgres's queryable JSON format. It can be filtered and indexed; it is not just an unstructured text file.
+ RowData is intentionally broad because different columns may hold text, numbers, booleans, dates, arrays, or nested JSON.
+
+
+
+
+
+ Why columns have IDs as well as names
+ A column's name is a label you may change. Its ID is its permanent address. Keeping those separate prevents a simple rename from becoming a rewrite of every row.
+
+
+
+
+
+
+ There is no automatic global translator. column-keys.ts is a toolbox of pure functions. A route or use case must explicitly say which vocabulary its caller speaks, build a map from the canonical table schema, and call the appropriate translator. “At the edge” means this deliberate handoff—not middleware that silently rewrites every object.
+
+
+ The schema is the dictionary
+ Suppose the canonical schema contains { id: "col_c", name: "Score" }. The map builders turn that one fact into the two dictionaries needed at different boundaries:
+
+
+
+ Inbound dictionary
+ buildIdByName(schema)
+ Map {
+ "Score" → "col_c"
+}
+ A public caller sends a recognizable name. The application converts it into the storage address.
+
+
+ Outbound dictionary
+ buildNameById(schema)
+ Map {
+ "col_c" → "Score"
+}
+ A public response starts with stored IDs and converts them back into labels the caller knows.
+
+
+
+ getColumnId(column) is the small compatibility rule underneath both dictionaries: use column.id when present, otherwise use column.name for an old pre-ID column whose rows were originally stored by name.
+
+ Journey 1: an external program asks Sim to change one cell
+ There is no actor called “a v2 API” spontaneously writing a small object. A person or another program first obtains a Sim API key and the IDs of the workspace, table, and row. That external program—perhaps a server, command-line script, or API client—then sends a complete HTTP request to Sim's v2 endpoint.
+
+ Here is an illustrative request to change the existing row row_123 in table tbl_companies. The IDs and API key below are placeholders, but the request's structure matches the contract:
+
+ PATCH /api/v2/tables/tbl_companies/rows/row_123 HTTP/1.1
+x-api-key: <a valid Sim API key>
+Content-Type: application/json
+
+{
+ "workspaceId": "ws_sales",
+ "data": {
+ "Score": 97
+ }
+}
+
+ The complete JSON body has two top-level fields: workspaceId and data. Only the object nested under data describes cell changes. Score is the human-facing column name and 97 is the new value. The table ID and row ID are in the URL path; the API key is in a header; the workspace ID and cell patch are in the JSON body.
+
+
+ The client does not send dataKeying or strictWrite. Those are private instructions added by the route after it has parsed the public request. They are not fields in the public HTTP body.
+
+
+
+ Client sends HTTPThe external program sends the URL, API-key header, and full JSON body shown above.
+ Route parses itThe route builder authenticates the key and validates the path and body against v2UpdateTableRowContract.
+ Route builds inputmapInput copies body.data and adds dataKeying: 'names' plus strictWrite: true.
+ Application writesThe use case translates Score → col_c. The row service validates 97 as a number and merges it into JSONB.
+
+
+
+ // app/api/v2/tables/[tableId]/rows/[rowId]/route.ts
+mapInput: ({ params, body }) => ({
+ tableId: params.tableId, // from the URL
+ rowId: params.rowId, // from the URL
+ assertedWorkspaceId: body.workspaceId,
+ data: body.data, // { "Score": 97 }
+ strictWrite: true, // added by route
+ dataKeying: 'names' as const, // added by route
+})
+
+// lib/table/application/rows.ts
+const data = rowDataToStorage(
+ input.data,
+ context.table,
+ input.dataKeying,
+ input.strictWrite
+)
+
+// Inside rowDataToStorage
+const idByName = buildIdByName(table.schema)
+if (strict) assertKnownColumnNames(data, idByName)
+return rowDataNameToId(data, idByName)
+
+ What happened to the request envelope? Authentication consumes the header. Contract parsing separates URL parameters from the body. mapInput then reshapes those pieces into an internal work order.
+ Why the use case? The route adapter must not query protected table data. The authorized use case is where the canonical schema becomes available.
+ Why the explicit dataKeying flag? Feeding an already ID-keyed row through the name translator would treat every ID as unknown and lose the cells.
+ Why strict mode? rowDataNameToId omits names it cannot map. v2 checks first and returns an “Unknown column” error instead of silently dropping a typo.
+
+
+
+ After the update succeeds, the endpoint responds to that same external program. The public response contains the row ID, the complete current row data translated back to column names, and timestamps. For example:
+
+ HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "data": {
+ "id": "row_123",
+ "data": {
+ "Company": "Acme",
+ "Score": 97
+ },
+ "createdAt": "2026-08-20T16:00:00.000Z",
+ "updatedAt": "2026-08-25T21:31:53.000Z"
+ }
+}
+
+ This response is illustrative: a real row may contain different or additional cells, and its timestamps will differ. The important point is that the request's data is a partial patch, while the successful response describes the current row.
+
+ Why the same line appears in your open rows/route.ts
+ Your open file handles the collection of rows rather than one row identified by rowId. Its callers still send complete requests. The exact public body depends on the operation:
+
+
+ | Operation | Public request body | Which values are name-keyed? |
+
+ Create one row
POST /rows | { "workspaceId": "ws_sales", "data": { "Score": 97 } } | The nested data object. |
+ Create several rows
POST /rows | { "workspaceId": "ws_sales", "rows": [{ "Score": 97 }, { "Score": 88 }] } | Each object inside rows. |
+ Update matching rows
PATCH /rows | { "workspaceId": "ws_sales", "filter": { "all": [{ "field": "Company", "op": "eq", "value": "Acme" }] }, "data": { "Score": 97 }, "limit": 10 } | The nested data object; filter.field also arrives as a column name and is translated separately. |
+
+
+
+ In every branch, dataKeying: 'names' is the route telling the use case how to interpret the cell objects it extracted from the larger request. The flag does not claim the entire request is { "Score": 97 }.
+
+ Journey 2: an API client asks Sim to return rows
+ The external program sends a GET request to a v2 rows endpoint with its API key and workspace query parameter. The database and row service return stored cell data such as { "col_c": 97 }. Before Sim constructs the HTTP response, the route's presenter uses the authorized table schema to turn that into the public, name-keyed form { "Score": 97 }.
+
+
+ // app/api/v2/tables/[tableId]/rows/route.ts
+const toNamedRow = namedRowMapper(table.schema.columns)
+return {
+ data: rows.map((row) => toApiRow(row, toNamedRow))
+}
+
+// Stored → public
+{ "col_c": 97 }
+ ↓
+{ "Score": 97 }
+
+ Outbound row translation deliberately lives in cell-format.ts, not column-keys.ts.
+ That mapper changes both the row key and, for a select column, the stored option ID into the option name. Fusing those operations prevents a public response such as { status: "opt_7" }.
+ Keys left behind by a recently deleted column are omitted because no current schema column gives them a public meaning.
+
+
+
+ Journey 3: you edit a cell in Sim's browser grid
+ The first-party grid has already fetched the table definition and built each display column's internal key with getColumnId(column). When you edit a Score cell, the grid's mutation code constructs the smaller cell patch below. Its React Query hook then places that patch inside the full internal API request body with the workspace ID:
+
+ // table-grid.tsx; columnName is actually the stable column key here
+mutateRef.current({
+ rowId,
+ data: { [columnName]: value } // { "col_c": 97 }
+})
+
+ The internal request is sent to PATCH /api/table/[tableId]/rows/[rowId] using your logged-in browser session. Its full JSON body is shaped like { "workspaceId": "ws_sales", "data": { "col_c": 97 } }. The internal route chooses dataKeying: 'ids'. In rowDataToStorage, the ID branch simply returns the nested data object unchanged. The response presenter also returns ID-keyed data to the session caller, so React Query can merge it directly into the ID-keyed cache. No name round trip occurs.
+
+
+ | Caller | Vocabulary on the wire | Where conversion happens |
+
+ | Workspace grid | Column IDs | None for ordinary rows; the grid already uses getColumnId. |
+ | v2 public API | Column names | Inbound in application/rows.ts; outbound in route presenters via namedRowMapper. |
+ | Delegated workflow caller on internal routes | Column names | rowKeyingForPrincipal selects the name path; the same use case normalizes the write. |
+ | CSV and exports | Column names | Import/export boundaries build the maps once and convert rows while streaming. |
+
+
+
+ Your open v2 find route is a smaller round trip
+ apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts passes the name-keyed predicate and sort to findTableRows. The use case converts predicate fields and sort fields to storage IDs before querying. A predicate needs one extra conversion: select option names must become stored option IDs, so it uses predicateToStorage rather than only predicateNamesToIds.
+
+
+ Public searchPredicate field Score, or select field/value names, arrive from v2.
+ Storage queryThe use case converts column names to IDs; select operands also become option IDs.
+ Match resultfindRowMatches reports the matching stored column as col_c.
+ Public responseThe presenter calls columnNameById(table.schema) and returns column: "Score".
+
+
+ That is the complete meaning of “translate at the edge”: each outward-facing surface chooses a human vocabulary, but translation only occurs at the specific inbound normalization or outbound presentation point where the authorized canonical schema is in hand.
+
+
+ This is a major design rule for new features. If something refers to a column over time—saved views, filters, workflow inputs, workflow outputs, widths, pinned state—it should normally refer to the stable ID, not the display name.
+
+
+
+
+ What happens when you edit one cell
+ The system gives you an instant-looking edit while still letting the server be the final authority.
+
+
+ The grid changes locallyTableGrid starts the mutation. React Query temporarily patches its cached row, so the interface feels immediate.
+ The route receives a typed requestrequestJson and the shared route contract agree on the request and response shape.
+ The use case checks contextThe application layer loads the canonical table, verifies workspace access, and chooses name-keyed or ID-keyed handling.
+ The service writes safelyThe row service merges the patch, coerces values, validates size and uniqueness, then updates the JSONB object.
+
+
+
+ // Simplified shape of the real update
+existing: { "col_a": "Acme", "col_c": 93 }
+patch: { "col_c": 97 }
+
+merged: { "col_a": "Acme", "col_c": 97 }
+
+// rows/service.ts persists a JSONB merge patch
+data = user_table_rows.data || patch
+
+ Only the changed cell needs to cross the wire; the row service reconstructs and validates the full result.
+ If the server rejects the edit, React Query restores the earlier cache. If it accepts, the returned value becomes authoritative.
+ Other viewers receive table events and refresh or patch their caches, so collaboration stays live.
+
+
+
+
+
+ Column types are behavior, not SQL columns
+ A “number column” does not create a Postgres numeric column. The value still lives inside JSONB, while a registry explains how that value should be edited, validated, displayed, filtered, sorted, and converted.
+
+
+ | Registry responsibility | Plain-language meaning |
+
+ coerce | Can input such as "42" safely become the number 42? |
+ validateCell | Does the stored value actually fit this column's promise? |
+ formatForDisplay | What should the person see in the grid or an export? |
+ editor | Should the cell use a text box, date control, select menu, or toggle? |
+ jsonbCast | When sorting or comparing inside Postgres, should JSON text be treated as a number or timestamp? |
+ ownedMetadata | Does this type carry extra configuration, such as select options or a currency code? |
+
+
+
+ The registry lives in apps/sim/lib/table/column-types/. The available types are currently text, number, currency, boolean, date, JSON, and select. Each has its own file, and registry.ts is the completeness gate that makes TypeScript complain if a new type is only partially wired.
+
+
+
+ The supporting records around the two core records
+ You can understand ordinary rows and columns with just user_table_definitions and user_table_rows. These side records explain the richer product behavior.
+
+
+ | Postgres record | Why it exists |
+
+ table_views | Saves a named filter, sort, hidden columns, widths, order, and pinned columns without mixing concurrent view edits into one big metadata blob. |
+ table_jobs | Tracks long-running imports, exports, bulk deletes, backfills, and updates, including progress and cancellation. |
+ table_row_executions | Tracks workflow or enrichment status for one row and one workflow group. Output values still land in the normal row JSON. |
+ table_run_dispatches | Tracks a user's “run this column / these rows” gesture while work is fanned out in batches. |
+ user_table_row_secret_provenance | Keeps security provenance beside the row without making the frequently-read cell JSON heavier. |
+
+
+
+ These are called “sidecars” in parts of the code: extra records attached to the main table or row for concerns that deserve their own indexes, lifecycle, or write pattern.
+
+
+
+ Your file-by-file walking tour
+ Read these in this order. It moves from the storage truth, through domain rules, toward the interface you see.
+
+
+ packages/db/schema.tsStart around userTableDefinitions and userTableRows. This is the physical Postgres shape and the clearest answer to “where does the data live?”
+ apps/sim/lib/table/types.tsRead ColumnDefinition, TableSchema, TableDefinition, TableRow, and RowData. This is the module's vocabulary.
+ apps/sim/lib/table/column-keys.tsLearn why storage uses column IDs and why public boundaries often use names. This file prevents renames from breaking references.
+ apps/sim/lib/table/column-types/See how a JSON value gains type-specific behavior across validation, editing, display, filters, sorting, and conversion.
+ apps/sim/lib/table/service.tsTable-level operations: create, fetch, list, rename, move, update metadata and locks, archive, and restore.
+ apps/sim/lib/table/rows/service.tsRow-level machinery: insert, query, paginate, update, upsert, and delete. This is large; begin with insertRow, queryRows, and updateRow.
+ apps/sim/lib/table/columns/service.tsSchema mutations: add, rename, retype, constrain, and delete columns. Notice that rename is metadata-only because values use stable IDs.
+ apps/sim/lib/table/application/The authorized use cases. These load canonical context, enforce access, call the lower-level services, record audits, and emit change signals.
+ apps/sim/lib/api/contracts/tables.tsThe shared HTTP promise between server and client: parameters, bodies, and response shapes.
+ apps/sim/app/api/table/Internal route adapters. A compact example is [tableId]/rows/[rowId]/route.ts: it maps HTTP into the shared row use cases.
+ apps/sim/hooks/queries/tables.tsThe client cache and mutations. Look at infinite row loading and the optimistic single-cell update.
+ apps/sim/app/workspace/[workspaceId]/tables/[tableId]/The page experience: table.tsx orchestrates the surface, while components/table-grid/ renders and edits the grid.
+
+
+
+
+ A request crosses these ownership layers
+ When you add a feature, place each part where its responsibility already lives. This is the architectural habit that matters more than memorizing individual functions.
+
+
+ ComponentInteraction and presentation: what the user clicks, sees, selects, drags, or edits.
+ React Query hookNetwork call, caching, optimistic behavior, and targeted refetching.
+ API contractThe exact request and response shapes shared by client and server.
+ Route adapterAuthentication, rate policy, contract parsing, input mapping, and response presentation.
+ Application use caseCanonical table lookup, workspace scope check, authorization, audit, and domain effects.
+ ServiceThe reusable table behavior and transaction: validate, query, mutate, and preserve invariants.
+ DB schemaThe durable physical shape, constraints, indexes, and cascading relationships.
+
+
+
+ A useful test: ask, “Would a workflow tool, API client, and browser grid need the same rule?” If yes, that rule probably belongs in the application or service layer—not only in the component or route.
+
+
+
+
+ The advanced machinery you can postpone
+ Do not try to understand the entire directory before making progress. These systems matter, but they are separate threads you can open only when your feature touches them.
+
+
+ Pagination and orderingRows have a fractional orderKey so inserting between two rows does not require renumbering the entire table. Infinite queries use cursors for efficient deep scrolling.
+ Filters and sortsThe query builder turns predicates into Postgres expressions that reach into JSONB. The column type registry supplies numeric and date casts.
+ Workflow columnsschema.workflowGroups maps table columns to workflow inputs and outputs. Results go into normal row data; execution status goes into a sidecar.
+ Realtime behaviorTable events feed an SSE stream for schema, metadata, edit, workflow status, and view changes. Presence and remote selections use the collaboration room.
+ Large operationsImports, exports, filtered deletes, backfills, and large updates become jobs so the request does not have to stay open while thousands of rows are processed.
+ Locks and provenanceMutation locks protect schema and row verbs. Secret provenance tracks whether values derived from secrets may safely re-enter model execution.
+
+
+
+
+ Common wrong turns to avoid
+
+ - “JSON means there is no schema.” The schema is explicit in
user_table_definitions.schema, and writes are coerced and validated against it.
+ - “Each visual column must be a physical Postgres column.” Visual cell values are keys inside the row's JSONB object.
+ - “Column names are safe references.” Names are labels. Long-lived internal references use stable column IDs.
+ - “Adding a column must rewrite every row.” Usually it only updates the table schema; absent row keys render empty.
+ - “Client validation is enough.” Workflows and APIs can bypass the grid, so shared rules belong on the server path.
+ - “A new feature only needs a UI change.” Durable behavior often spans storage, types, contracts, authorization, services, cache reconciliation, and events.
+
+
+
+
+ The mental model to carry into your feature
+
+ Definition A table is a record containing its identity, column schema, layout metadata, limits, and locks.
+ Rows Every visible row is another record whose data field is JSONB keyed by stable column IDs.
+ Behavior Column types explain how those JSON values are coerced, validated, rendered, filtered, sorted, and edited.
+ Boundaries The browser, public APIs, workflows, and background jobs all converge on authorized use cases and shared services.
+ Rich features Views, jobs, workflow runs, and provenance live in side records so the hot row data stays focused.
+ Feature design Decide whether your change affects the definition, row JSON, a sidecar, or several layers—and keep each rule in its owning layer.
+
+
+
+ If you remember only one sentence: Sim Tables are a database-backed, schema-aware grid where each row's cells are stored together as JSONB under stable column IDs.
+
+
+
+
+
+
+
| |