From 26264822690dc64ce81028b23d6a54bf60ab1493 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 12 Jun 2026 10:45:07 -0700 Subject: [PATCH 1/7] ci(migrations): fail dev schema push with an actionable error on rename/drop prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drizzle-kit push --force` only suppresses the data-loss confirm, not the rename-vs-drop disambiguation prompt. That prompt fires whenever a diff both adds and drops tables/columns at once (e.g. migration 0231 created sim_trigger_state while dropping the workspace_notification_* tables), and in CI it crashes with a bare "Interactive prompts require a TTY" stack trace. Catch that specific failure in the dev push step and emit a GitHub error annotation explaining the cause and the fix (drop the stale objects on the dev DB to match schema.ts — the same DROPs the versioned migration already applied to staging/prod), instead of leaving an opaque trace. Exit status is preserved either way. Co-Authored-By: Claude Fable 5 --- .github/workflows/migrations.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index ea5ca453968..7be6e56b32d 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -69,7 +69,18 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" - bun run db:push --force + # `--force` only suppresses the data-loss confirm, not drizzle's + # rename-vs-drop prompt, which fires (and crashes, no TTY) when a + # diff both adds and drops tables/columns at once. Turn that opaque + # crash into an actionable failure instead of a bare stack trace. + push_output="$(bun run db:push --force 2>&1)" && push_status=0 || push_status=$? + echo "$push_output" + if [ "$push_status" -ne 0 ]; then + if printf '%s' "$push_output" | grep -q 'Interactive prompts require a TTY'; then + echo "::error title=Dev schema push needs manual reconciliation::drizzle-kit push hit an interactive rename/drop prompt that CI cannot answer. The dev DB has drifted from schema.ts: it still holds table(s)/column(s) the schema no longer declares while the schema also adds new ones, so drizzle cannot tell a rename from a drop+create. Fix: drop the stale objects on the dev DB to match schema.ts — the same DROPs the latest versioned migration already applied to staging/prod (grep packages/db/migrations for the most recent DROP TABLE / DROP COLUMN) — then re-run this workflow. --force cannot bypass this prompt." + fi + exit "$push_status" + fi else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts From 766ad33410253485ed14f7ac0de38bbf620bc397 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 17 Jun 2026 16:16:54 -0700 Subject: [PATCH 2/7] improvement(tables): empty-state filter/sort builders + upsert conflict-column selection --- .../filter-builder/filter-builder.tsx | 38 +++++++++---------- .../components/sort-builder/sort-builder.tsx | 28 +++++++++----- apps/sim/blocks/blocks/table.ts | 17 +++++++++ .../sim/lib/table/query-builder/converters.ts | 4 ++ apps/sim/lib/table/rows/service.ts | 7 +--- apps/sim/tools/table/types.ts | 2 + apps/sim/tools/table/upsert_row.ts | 8 ++++ 7 files changed, 71 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx index 6d43669249e..52fc56e7a41 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx @@ -1,8 +1,8 @@ 'use client' import { useCallback, useMemo } from 'react' -import { generateId } from '@sim/utils/id' -import type { ComboboxOption } from '@/components/emcn' +import { Plus } from 'lucide-react' +import { Button } from '@/components/emcn' import { useTableColumns } from '@/lib/table/hooks' import type { FilterRule } from '@/lib/table/query-builder/constants' import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder' @@ -22,15 +22,6 @@ interface FilterBuilderProps { tableIdSubBlockId?: string } -const createDefaultRule = (columns: ComboboxOption[]): FilterRule => ({ - id: generateId(), - logicalOperator: 'and', - column: columns[0]?.value || '', - operator: 'eq', - value: '', - collapsed: false, -}) - /** Visual builder for table filter rules in workflow blocks. */ export function FilterBuilder({ blockId, @@ -52,8 +43,7 @@ export function FilterBuilder({ }, [propColumns, dynamicColumns]) const value = isPreview ? previewValue : storeValue - const rules: FilterRule[] = - Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)] + const rules: FilterRule[] = Array.isArray(value) ? value : [] const isReadOnly = isPreview || disabled const { comparisonOptions, logicalOptions, addRule, removeRule, updateRule } = useFilterBuilder({ @@ -86,15 +76,25 @@ export function FilterBuilder({ const handleRemoveRule = useCallback( (id: string) => { if (isReadOnly) return - if (rules.length === 1) { - setStoreValue([createDefaultRule(columns)]) - } else { - removeRule(id) - } + removeRule(id) }, - [isReadOnly, rules, columns, setStoreValue, removeRule] + [isReadOnly, removeRule] ) + if (rules.length === 0) { + if (isReadOnly) return null + return ( + + ) + } + return (
{rules.map((rule, index) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx index 38a608841a2..6095ada1df3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx @@ -2,7 +2,8 @@ import { useCallback, useMemo } from 'react' import { generateId } from '@sim/utils/id' -import type { ComboboxOption } from '@/components/emcn' +import { Plus } from 'lucide-react' +import { Button, type ComboboxOption } from '@/components/emcn' import { useTableColumns } from '@/lib/table/hooks' import { SORT_DIRECTIONS, type SortRule } from '@/lib/table/query-builder/constants' import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value' @@ -51,8 +52,7 @@ export function SortBuilder({ ) const value = isPreview ? previewValue : storeValue - const rules: SortRule[] = - Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)] + const rules: SortRule[] = Array.isArray(value) ? value : [] const isReadOnly = isPreview || disabled const addRule = useCallback(() => { @@ -63,13 +63,9 @@ export function SortBuilder({ const removeRule = useCallback( (id: string) => { if (isReadOnly) return - if (rules.length === 1) { - setStoreValue([createDefaultRule(columns)]) - } else { - setStoreValue(rules.filter((r) => r.id !== id)) - } + setStoreValue(rules.filter((r) => r.id !== id)) }, - [isReadOnly, rules, columns, setStoreValue] + [isReadOnly, rules, setStoreValue] ) const updateRule = useCallback( @@ -88,6 +84,20 @@ export function SortBuilder({ [isReadOnly, rules, setStoreValue] ) + if (rules.length === 0) { + if (isReadOnly) return null + return ( + + ) + } + return (
{rules.map((rule, index) => ( diff --git a/apps/sim/blocks/blocks/table.ts b/apps/sim/blocks/blocks/table.ts index 80dba4a7015..5241823f346 100644 --- a/apps/sim/blocks/blocks/table.ts +++ b/apps/sim/blocks/blocks/table.ts @@ -58,6 +58,7 @@ interface TableBlockParams { sortBuilder?: unknown bulkFilterMode?: string bulkFilterBuilder?: unknown + conflictColumn?: string } /** Normalized params after parsing, ready for tool request body */ @@ -70,6 +71,7 @@ interface ParsedParams { sort?: unknown limit?: number offset?: number + conflictTarget?: string } /** Transforms raw block params into tool request params for each operation */ @@ -82,6 +84,7 @@ const paramTransformers: Record ParsedPara upsert_row: (params) => ({ tableId: params.tableId, data: parseJSON(params.data, 'Row Data'), + conflictTarget: params.conflictColumn || undefined, }), batch_insert_rows: (params) => ({ @@ -275,6 +278,16 @@ Return ONLY the data JSON:`, }, }, + // Upsert - which unique column to match on (defaults to the first unique column) + { + id: 'conflictColumn', + title: 'Conflict Column', + type: 'short-input', + placeholder: 'Unique column to match on (defaults to the first unique column)', + dependsOn: ['tableId'], + condition: { field: 'operation', value: 'upsert_row' }, + }, + // Batch Insert - multiple rows { id: 'rows', @@ -631,6 +644,10 @@ Return ONLY the sort JSON:`, sortBuilder: { type: 'json', description: 'Visual sort builder conditions' }, sort: { type: 'json', description: 'Sort order (JSON)' }, offset: { type: 'number', description: 'Query result offset' }, + conflictColumn: { + type: 'string', + description: 'Unique column to match on for upsert (defaults to the first unique column)', + }, }, outputs: { diff --git a/apps/sim/lib/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts index 4dc29f4b0ae..b55a9ffa15d 100644 --- a/apps/sim/lib/table/query-builder/converters.ts +++ b/apps/sim/lib/table/query-builder/converters.ts @@ -21,6 +21,10 @@ export function filterRulesToFilter(rules: FilterRule[]): Filter | null { let currentGroup: Filter = {} for (const rule of rules) { + // Skip incomplete rows (no column selected) so a blank builder row never + // serializes to a `{ '': ... }` predicate. + if (!rule.column) continue + const isOr = rule.logicalOperator === 'or' const ruleValue = toRuleValue(rule.operator, rule.value) diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index c676b178130..95e51ccabb3 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -492,12 +492,9 @@ export async function upsertRow( ) } targetColumnKey = getColumnId(col) - } else if (uniqueColumns.length === 1) { - targetColumnKey = getColumnId(uniqueColumns[0]) } else { - throw new Error( - `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify conflictTarget to indicate which column to match on.` - ) + // No conflict target specified — default to the first unique column. + targetColumnKey = getColumnId(uniqueColumns[0]) } // Validate row data diff --git a/apps/sim/tools/table/types.ts b/apps/sim/tools/table/types.ts index 3fcf8a8a202..baf836c0940 100644 --- a/apps/sim/tools/table/types.ts +++ b/apps/sim/tools/table/types.ts @@ -23,6 +23,8 @@ export interface TableListParams { export interface TableRowInsertParams { tableId: string data: RowData + /** Unique column to match on for upsert; ignored by plain insert */ + conflictTarget?: string _context?: WorkflowToolExecutionContext } diff --git a/apps/sim/tools/table/upsert_row.ts b/apps/sim/tools/table/upsert_row.ts index 77f6a0a6d8d..0046974c38d 100644 --- a/apps/sim/tools/table/upsert_row.ts +++ b/apps/sim/tools/table/upsert_row.ts @@ -28,6 +28,13 @@ export const tableUpsertRowTool: ToolConfig Date: Wed, 17 Jun 2026 16:36:48 -0700 Subject: [PATCH 3/7] improvement(tables): throw on ambiguous upsert instead of guessing the conflict column --- apps/sim/blocks/blocks/table.ts | 5 +++-- apps/sim/lib/table/rows/service.ts | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/sim/blocks/blocks/table.ts b/apps/sim/blocks/blocks/table.ts index 5241823f346..b7a4af04f0f 100644 --- a/apps/sim/blocks/blocks/table.ts +++ b/apps/sim/blocks/blocks/table.ts @@ -283,7 +283,7 @@ Return ONLY the data JSON:`, id: 'conflictColumn', title: 'Conflict Column', type: 'short-input', - placeholder: 'Unique column to match on (defaults to the first unique column)', + placeholder: 'Unique column to match on (required if the table has multiple unique columns)', dependsOn: ['tableId'], condition: { field: 'operation', value: 'upsert_row' }, }, @@ -646,7 +646,8 @@ Return ONLY the sort JSON:`, offset: { type: 'number', description: 'Query result offset' }, conflictColumn: { type: 'string', - description: 'Unique column to match on for upsert (defaults to the first unique column)', + description: + 'Unique column to match on for upsert (required if the table has multiple unique columns)', }, }, diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 95e51ccabb3..06b3c8e3432 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -492,9 +492,12 @@ export async function upsertRow( ) } targetColumnKey = getColumnId(col) - } else { - // No conflict target specified — default to the first unique column. + } else if (uniqueColumns.length === 1) { targetColumnKey = getColumnId(uniqueColumns[0]) + } else { + throw new Error( + `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.` + ) } // Validate row data From 4d057c6158565614d632a872dc373ba0dd23a95b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 17 Jun 2026 19:44:03 -0700 Subject: [PATCH 4/7] Revert "ci(migrations): fail dev schema push with an actionable error on rename/drop prompt" This reverts commit 26264822690dc64ce81028b23d6a54bf60ab1493. --- .github/workflows/migrations.yml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 7be6e56b32d..ea5ca453968 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -69,18 +69,7 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" - # `--force` only suppresses the data-loss confirm, not drizzle's - # rename-vs-drop prompt, which fires (and crashes, no TTY) when a - # diff both adds and drops tables/columns at once. Turn that opaque - # crash into an actionable failure instead of a bare stack trace. - push_output="$(bun run db:push --force 2>&1)" && push_status=0 || push_status=$? - echo "$push_output" - if [ "$push_status" -ne 0 ]; then - if printf '%s' "$push_output" | grep -q 'Interactive prompts require a TTY'; then - echo "::error title=Dev schema push needs manual reconciliation::drizzle-kit push hit an interactive rename/drop prompt that CI cannot answer. The dev DB has drifted from schema.ts: it still holds table(s)/column(s) the schema no longer declares while the schema also adds new ones, so drizzle cannot tell a rename from a drop+create. Fix: drop the stale objects on the dev DB to match schema.ts — the same DROPs the latest versioned migration already applied to staging/prod (grep packages/db/migrations for the most recent DROP TABLE / DROP COLUMN) — then re-run this workflow. --force cannot bypass this prompt." - fi - exit "$push_status" - fi + bun run db:push --force else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts From bfaa36642b08f5fe6a4f6d8cf0ef3499a4aa21e6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 18 Jun 2026 14:06:24 -0700 Subject: [PATCH 5/7] improvement(tables): unique-column picker for upsert + richer get-schema (counts, ids, live plan row limit) --- apps/sim/app/api/table/[tableId]/route.ts | 7 +++- .../editor/components/sub-block/sub-block.tsx | 1 + apps/sim/blocks/blocks.test.ts | 1 + apps/sim/blocks/blocks/table.ts | 36 +++++++++++++++---- apps/sim/hooks/queries/tables.ts | 12 +++++++ .../selectors/providers/sim/selectors.ts | 35 +++++++++++++++++- apps/sim/hooks/selectors/types.ts | 2 ++ apps/sim/lib/workflows/subblocks/context.ts | 1 + apps/sim/tools/table/get_schema.ts | 22 ++++++++++-- apps/sim/tools/table/types.ts | 3 ++ packages/workflow-types/src/blocks.ts | 1 + 11 files changed, 111 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 0d185a74784..bd398867eeb 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table' +import { getWorkspaceTableLimits } from '@/lib/table/billing' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableDetailAPI') @@ -46,6 +47,10 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab const schemaData = table.schema as TableSchema + // Source the row cap from the workspace's live plan, not the value stored on + // the table at creation time (which goes stale when the plan changes). + const { maxRowsPerTable } = await getWorkspaceTableLimits(table.workspaceId) + return NextResponse.json({ success: true, data: { @@ -59,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab }, metadata: table.metadata ?? null, rowCount: table.rowCount, - maxRows: table.maxRows, + maxRows: maxRowsPerTable, createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index a27157e1265..71c94d10337 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -929,6 +929,7 @@ function SubBlockComponent({ case 'file-selector': case 'sheet-selector': case 'project-selector': + case 'column-selector': return ( { 'text', 'router-input', 'table-selector', + 'column-selector', 'filter-builder', 'sort-builder', 'skill-input', diff --git a/apps/sim/blocks/blocks/table.ts b/apps/sim/blocks/blocks/table.ts index b7a4af04f0f..281bcd077e7 100644 --- a/apps/sim/blocks/blocks/table.ts +++ b/apps/sim/blocks/blocks/table.ts @@ -278,12 +278,26 @@ Return ONLY the data JSON:`, }, }, - // Upsert - which unique column to match on (defaults to the first unique column) + // Upsert - which unique column to match on (required when 2+ unique columns) + // Basic: pick a unique column. Advanced: enter the column id directly. { - id: 'conflictColumn', + id: 'conflictColumnSelector', + title: 'Conflict Column', + type: 'column-selector', + canonicalParamId: 'conflictColumn', + mode: 'basic', + selectorKey: 'table.columns', + placeholder: 'Select a unique column', + dependsOn: ['tableSelector'], + condition: { field: 'operation', value: 'upsert_row' }, + }, + { + id: 'manualConflictColumn', title: 'Conflict Column', type: 'short-input', - placeholder: 'Unique column to match on (required if the table has multiple unique columns)', + canonicalParamId: 'conflictColumn', + mode: 'advanced', + placeholder: 'Enter the column id', dependsOn: ['tableId'], condition: { field: 'operation', value: 'upsert_row' }, }, @@ -673,8 +687,8 @@ Return ONLY the sort JSON:`, }, rowCount: { type: 'number', - description: 'Number of rows returned', - condition: { field: 'operation', value: 'query_rows' }, + description: 'Rows returned (query) or total rows in the table (get schema)', + condition: { field: 'operation', value: ['query_rows', 'get_schema'] }, }, totalCount: { type: 'number', @@ -713,7 +727,17 @@ Return ONLY the sort JSON:`, }, columns: { type: 'array', - description: 'Column definitions', + description: 'Column definitions (each includes its stable id)', + condition: { field: 'operation', value: 'get_schema' }, + }, + columnCount: { + type: 'number', + description: 'Number of columns', + condition: { field: 'operation', value: 'get_schema' }, + }, + maxRows: { + type: 'number', + description: "Max rows per table for the workspace's plan", condition: { field: 'operation', value: 'get_schema' }, }, message: { type: 'string', description: 'Operation status message' }, diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index c9792a831d8..faf68b25d62 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -266,6 +266,18 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde }) } +/** + * Shared table-detail query options so non-component callers (e.g. selector + * providers) can `ensureQueryData` the same cache entry `useTable` populates. + */ +export function getTableDetailQueryOptions(workspaceId: string, tableId: string) { + return { + queryKey: tableKeys.detail(tableId), + queryFn: ({ signal }: { signal?: AbortSignal }) => fetchTable(workspaceId, tableId, signal), + staleTime: 30 * 1000, + } +} + export interface TableRunState { dispatches: ActiveDispatch[] runningCellCount: number diff --git a/apps/sim/hooks/selectors/providers/sim/selectors.ts b/apps/sim/hooks/selectors/providers/sim/selectors.ts index 45881d47e7c..646821efe76 100644 --- a/apps/sim/hooks/selectors/providers/sim/selectors.ts +++ b/apps/sim/hooks/selectors/providers/sim/selectors.ts @@ -1,4 +1,6 @@ +import { getColumnId } from '@/lib/table/column-keys' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { getTableDetailQueryOptions } from '@/hooks/queries/tables' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getFolderPath } from '@/hooks/queries/utils/folder-tree' import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' @@ -12,6 +14,7 @@ import type { SelectorQueryArgs, } from '@/hooks/selectors/types' import type { WorkflowFolder } from '@/stores/folders/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' /** @@ -85,4 +88,34 @@ export const simSelectors = { } }, }, -} satisfies Record, SelectorDefinition> + 'table.columns': { + key: 'table.columns', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context, search }: SelectorQueryArgs) => [ + ...selectorKeys.all, + 'table.columns', + context.tableId ?? 'none', + search ?? '', + ], + enabled: ({ context }) => Boolean(context.tableId), + fetchList: async ({ context }: SelectorQueryArgs): Promise => { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId || !context.tableId) return [] + const table = await getQueryClient().ensureQueryData( + getTableDetailQueryOptions(workspaceId, context.tableId) + ) + return (table.schema?.columns ?? []) + .filter((col) => col.unique) + .map((col) => ({ id: getColumnId(col), label: col.name })) + }, + fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise => { + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!detailId || !workspaceId || !context.tableId) return null + const table = await getQueryClient().ensureQueryData( + getTableDetailQueryOptions(workspaceId, context.tableId) + ) + const col = (table.schema?.columns ?? []).find((c) => getColumnId(c) === detailId) + return col ? { id: getColumnId(col), label: col.name } : null + }, + }, +} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 48af9cfda80..0b26fdd130f 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -56,6 +56,7 @@ export type SelectorKey = | 'monday.boards' | 'monday.groups' | 'sim.workflows' + | 'table.columns' export interface SelectorOption { id: string @@ -91,6 +92,7 @@ export interface SelectorContext { awsRegion?: string logGroupName?: string mcpServerId?: string + tableId?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index fd32f9d696a..a1b6a9076bd 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -28,6 +28,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'awsSecretAccessKey', 'awsRegion', 'logGroupName', + 'tableId', ]) /** diff --git a/apps/sim/tools/table/get_schema.ts b/apps/sim/tools/table/get_schema.ts index 2bea5755af7..7f96f0dd065 100644 --- a/apps/sim/tools/table/get_schema.ts +++ b/apps/sim/tools/table/get_schema.ts @@ -1,3 +1,5 @@ +import { getColumnId } from '@/lib/table/column-keys' +import type { ColumnDefinition } from '@/lib/table/types' import type { TableGetSchemaParams, TableGetSchemaResponse } from '@/tools/table/types' import type { ToolConfig } from '@/tools/types' @@ -35,11 +37,21 @@ export const tableGetSchemaTool: ToolConfig ({ ...col, id: getColumnId(col) })) + return { success: true, output: { name: data.table.name, - columns: data.table.schema.columns, + columns, + columnCount: columns.length, + rowCount: data.table.rowCount ?? 0, + maxRows: data.table.maxRows ?? 0, message: data.message || 'Schema retrieved successfully', }, } @@ -48,7 +60,13 @@ export const tableGetSchemaTool: ToolConfig Date: Thu, 18 Jun 2026 14:19:23 -0700 Subject: [PATCH 6/7] fix(tables): honor OR boundary when skipping incomplete filter rows --- apps/sim/lib/table/query-builder/converters.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts index b55a9ffa15d..4d6b5f8753e 100644 --- a/apps/sim/lib/table/query-builder/converters.ts +++ b/apps/sim/lib/table/query-builder/converters.ts @@ -21,18 +21,20 @@ export function filterRulesToFilter(rules: FilterRule[]): Filter | null { let currentGroup: Filter = {} for (const rule of rules) { - // Skip incomplete rows (no column selected) so a blank builder row never - // serializes to a `{ '': ... }` predicate. - if (!rule.column) continue - + // Honor the OR boundary before skipping incomplete rows, so an incomplete + // `or` row between two valid conditions still starts a new group. const isOr = rule.logicalOperator === 'or' - const ruleValue = toRuleValue(rule.operator, rule.value) - if (isOr && Object.keys(currentGroup).length > 0) { orGroups.push({ ...currentGroup }) currentGroup = {} } + // Skip incomplete rows (no column selected) so a blank builder row never + // serializes to a `{ '': ... }` predicate. The OR boundary above is still + // applied; the row just contributes no condition. + if (!rule.column) continue + + const ruleValue = toRuleValue(rule.operator, rule.value) const existing = currentGroup[rule.column] currentGroup[rule.column] = existing === undefined From d2fbab1b6b4acfcb099868c8e42539729009aea6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 18 Jun 2026 14:33:56 -0700 Subject: [PATCH 7/7] fix(tables): source workspaceId for column selector from route context --- .../sub-block/hooks/use-selector-setup.ts | 3 +++ .../sim/hooks/selectors/providers/sim/selectors.ts | 14 ++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index 555496c899a..9e1e101f6f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -31,6 +31,7 @@ export function useSelectorSetup( const params = useParams() const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const workflowId = (params?.workflowId as string) || activeWorkflowId || '' + const workspaceId = (params?.workspaceId as string) || '' const { data: envVariables = {} } = usePersonalEnvironment() @@ -63,6 +64,7 @@ export function useSelectorSetup( const selectorContext = useMemo(() => { const context: SelectorContext = { workflowId, + workspaceId: workspaceId || undefined, mimeType: subBlock.mimeType, } @@ -87,6 +89,7 @@ export function useSelectorSetup( resolvedDependencyValues, canonicalIndex, workflowId, + workspaceId, subBlock.mimeType, impersonateUserEmail, ]) diff --git a/apps/sim/hooks/selectors/providers/sim/selectors.ts b/apps/sim/hooks/selectors/providers/sim/selectors.ts index 646821efe76..1c7784cd982 100644 --- a/apps/sim/hooks/selectors/providers/sim/selectors.ts +++ b/apps/sim/hooks/selectors/providers/sim/selectors.ts @@ -14,7 +14,6 @@ import type { SelectorQueryArgs, } from '@/hooks/selectors/types' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' /** @@ -94,25 +93,24 @@ export const simSelectors = { getQueryKey: ({ context, search }: SelectorQueryArgs) => [ ...selectorKeys.all, 'table.columns', + context.workspaceId ?? 'none', context.tableId ?? 'none', search ?? '', ], - enabled: ({ context }) => Boolean(context.tableId), + enabled: ({ context }) => Boolean(context.workspaceId && context.tableId), fetchList: async ({ context }: SelectorQueryArgs): Promise => { - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId - if (!workspaceId || !context.tableId) return [] + if (!context.workspaceId || !context.tableId) return [] const table = await getQueryClient().ensureQueryData( - getTableDetailQueryOptions(workspaceId, context.tableId) + getTableDetailQueryOptions(context.workspaceId, context.tableId) ) return (table.schema?.columns ?? []) .filter((col) => col.unique) .map((col) => ({ id: getColumnId(col), label: col.name })) }, fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise => { - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId - if (!detailId || !workspaceId || !context.tableId) return null + if (!detailId || !context.workspaceId || !context.tableId) return null const table = await getQueryClient().ensureQueryData( - getTableDetailQueryOptions(workspaceId, context.tableId) + getTableDetailQueryOptions(context.workspaceId, context.tableId) ) const col = (table.schema?.columns ?? []).find((c) => getColumnId(c) === detailId) return col ? { id: getColumnId(col), label: col.name } : null