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/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/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/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 80dba4a7015..281bcd077e7 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,30 @@ Return ONLY the data JSON:`, }, }, + // Upsert - which unique column to match on (required when 2+ unique columns) + // Basic: pick a unique column. Advanced: enter the column id directly. + { + 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', + canonicalParamId: 'conflictColumn', + mode: 'advanced', + placeholder: 'Enter the column id', + dependsOn: ['tableId'], + condition: { field: 'operation', value: 'upsert_row' }, + }, + // Batch Insert - multiple rows { id: 'rows', @@ -631,6 +658,11 @@ 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 (required if the table has multiple unique columns)', + }, }, outputs: { @@ -655,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', @@ -695,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 5825ebd6c47..4bc2a19ce33 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -267,6 +267,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..1c7784cd982 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' @@ -85,4 +87,33 @@ export const simSelectors = { } }, }, -} satisfies Record, SelectorDefinition> + 'table.columns': { + key: 'table.columns', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context, search }: SelectorQueryArgs) => [ + ...selectorKeys.all, + 'table.columns', + context.workspaceId ?? 'none', + context.tableId ?? 'none', + search ?? '', + ], + enabled: ({ context }) => Boolean(context.workspaceId && context.tableId), + fetchList: async ({ context }: SelectorQueryArgs): Promise => { + if (!context.workspaceId || !context.tableId) return [] + const table = await getQueryClient().ensureQueryData( + 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 => { + if (!detailId || !context.workspaceId || !context.tableId) return null + const table = await getQueryClient().ensureQueryData( + getTableDetailQueryOptions(context.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/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts index 4dc29f4b0ae..4d6b5f8753e 100644 --- a/apps/sim/lib/table/query-builder/converters.ts +++ b/apps/sim/lib/table/query-builder/converters.ts @@ -21,14 +21,20 @@ export function filterRulesToFilter(rules: FilterRule[]): Filter | null { let currentGroup: Filter = {} for (const rule of rules) { + // 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 diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 5cc156dbfa9..7f9878a7e16 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -516,7 +516,7 @@ export async function upsertRow( 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.` + `Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.` ) } 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