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