Skip to content

Commit bfaa366

Browse files
improvement(tables): unique-column picker for upsert + richer get-schema (counts, ids, live plan row limit)
1 parent 4d057c6 commit bfaa366

11 files changed

Lines changed: 111 additions & 10 deletions

File tree

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { captureServerEvent } from '@/lib/posthog/server'
1010
import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table'
11+
import { getWorkspaceTableLimits } from '@/lib/table/billing'
1112
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
1213

1314
const logger = createLogger('TableDetailAPI')
@@ -46,6 +47,10 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
4647

4748
const schemaData = table.schema as TableSchema
4849

50+
// Source the row cap from the workspace's live plan, not the value stored on
51+
// the table at creation time (which goes stale when the plan changes).
52+
const { maxRowsPerTable } = await getWorkspaceTableLimits(table.workspaceId)
53+
4954
return NextResponse.json({
5055
success: true,
5156
data: {
@@ -59,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
5964
},
6065
metadata: table.metadata ?? null,
6166
rowCount: table.rowCount,
62-
maxRows: table.maxRows,
67+
maxRows: maxRowsPerTable,
6368
createdAt:
6469
table.createdAt instanceof Date
6570
? table.createdAt.toISOString()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,7 @@ function SubBlockComponent({
929929
case 'file-selector':
930930
case 'sheet-selector':
931931
case 'project-selector':
932+
case 'column-selector':
932933
return (
933934
<SelectorInput
934935
blockId={blockId}

apps/sim/blocks/blocks.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ describe.concurrent('Blocks Module', () => {
580580
'text',
581581
'router-input',
582582
'table-selector',
583+
'column-selector',
583584
'filter-builder',
584585
'sort-builder',
585586
'skill-input',

apps/sim/blocks/blocks/table.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,26 @@ Return ONLY the data JSON:`,
278278
},
279279
},
280280

281-
// Upsert - which unique column to match on (defaults to the first unique column)
281+
// Upsert - which unique column to match on (required when 2+ unique columns)
282+
// Basic: pick a unique column. Advanced: enter the column id directly.
282283
{
283-
id: 'conflictColumn',
284+
id: 'conflictColumnSelector',
285+
title: 'Conflict Column',
286+
type: 'column-selector',
287+
canonicalParamId: 'conflictColumn',
288+
mode: 'basic',
289+
selectorKey: 'table.columns',
290+
placeholder: 'Select a unique column',
291+
dependsOn: ['tableSelector'],
292+
condition: { field: 'operation', value: 'upsert_row' },
293+
},
294+
{
295+
id: 'manualConflictColumn',
284296
title: 'Conflict Column',
285297
type: 'short-input',
286-
placeholder: 'Unique column to match on (required if the table has multiple unique columns)',
298+
canonicalParamId: 'conflictColumn',
299+
mode: 'advanced',
300+
placeholder: 'Enter the column id',
287301
dependsOn: ['tableId'],
288302
condition: { field: 'operation', value: 'upsert_row' },
289303
},
@@ -673,8 +687,8 @@ Return ONLY the sort JSON:`,
673687
},
674688
rowCount: {
675689
type: 'number',
676-
description: 'Number of rows returned',
677-
condition: { field: 'operation', value: 'query_rows' },
690+
description: 'Rows returned (query) or total rows in the table (get schema)',
691+
condition: { field: 'operation', value: ['query_rows', 'get_schema'] },
678692
},
679693
totalCount: {
680694
type: 'number',
@@ -713,7 +727,17 @@ Return ONLY the sort JSON:`,
713727
},
714728
columns: {
715729
type: 'array',
716-
description: 'Column definitions',
730+
description: 'Column definitions (each includes its stable id)',
731+
condition: { field: 'operation', value: 'get_schema' },
732+
},
733+
columnCount: {
734+
type: 'number',
735+
description: 'Number of columns',
736+
condition: { field: 'operation', value: 'get_schema' },
737+
},
738+
maxRows: {
739+
type: 'number',
740+
description: "Max rows per table for the workspace's plan",
717741
condition: { field: 'operation', value: 'get_schema' },
718742
},
719743
message: { type: 'string', description: 'Operation status message' },

apps/sim/hooks/queries/tables.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,18 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde
266266
})
267267
}
268268

269+
/**
270+
* Shared table-detail query options so non-component callers (e.g. selector
271+
* providers) can `ensureQueryData` the same cache entry `useTable` populates.
272+
*/
273+
export function getTableDetailQueryOptions(workspaceId: string, tableId: string) {
274+
return {
275+
queryKey: tableKeys.detail(tableId),
276+
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchTable(workspaceId, tableId, signal),
277+
staleTime: 30 * 1000,
278+
}
279+
}
280+
269281
export interface TableRunState {
270282
dispatches: ActiveDispatch[]
271283
runningCellCount: number

apps/sim/hooks/selectors/providers/sim/selectors.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { getColumnId } from '@/lib/table/column-keys'
12
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
3+
import { getTableDetailQueryOptions } from '@/hooks/queries/tables'
24
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
35
import { getFolderPath } from '@/hooks/queries/utils/folder-tree'
46
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
@@ -12,6 +14,7 @@ import type {
1214
SelectorQueryArgs,
1315
} from '@/hooks/selectors/types'
1416
import type { WorkflowFolder } from '@/stores/folders/types'
17+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1518
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
1619

1720
/**
@@ -85,4 +88,34 @@ export const simSelectors = {
8588
}
8689
},
8790
},
88-
} satisfies Record<Extract<SelectorKey, 'sim.workflows'>, SelectorDefinition>
91+
'table.columns': {
92+
key: 'table.columns',
93+
staleTime: SELECTOR_STALE,
94+
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
95+
...selectorKeys.all,
96+
'table.columns',
97+
context.tableId ?? 'none',
98+
search ?? '',
99+
],
100+
enabled: ({ context }) => Boolean(context.tableId),
101+
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
102+
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
103+
if (!workspaceId || !context.tableId) return []
104+
const table = await getQueryClient().ensureQueryData(
105+
getTableDetailQueryOptions(workspaceId, context.tableId)
106+
)
107+
return (table.schema?.columns ?? [])
108+
.filter((col) => col.unique)
109+
.map((col) => ({ id: getColumnId(col), label: col.name }))
110+
},
111+
fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
112+
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
113+
if (!detailId || !workspaceId || !context.tableId) return null
114+
const table = await getQueryClient().ensureQueryData(
115+
getTableDetailQueryOptions(workspaceId, context.tableId)
116+
)
117+
const col = (table.schema?.columns ?? []).find((c) => getColumnId(c) === detailId)
118+
return col ? { id: getColumnId(col), label: col.name } : null
119+
},
120+
},
121+
} satisfies Record<Extract<SelectorKey, 'sim.workflows' | 'table.columns'>, SelectorDefinition>

apps/sim/hooks/selectors/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type SelectorKey =
5656
| 'monday.boards'
5757
| 'monday.groups'
5858
| 'sim.workflows'
59+
| 'table.columns'
5960

6061
export interface SelectorOption {
6162
id: string
@@ -91,6 +92,7 @@ export interface SelectorContext {
9192
awsRegion?: string
9293
logGroupName?: string
9394
mcpServerId?: string
95+
tableId?: string
9496
}
9597

9698
export interface SelectorQueryArgs {

apps/sim/lib/workflows/subblocks/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
2828
'awsSecretAccessKey',
2929
'awsRegion',
3030
'logGroupName',
31+
'tableId',
3132
])
3233

3334
/**

apps/sim/tools/table/get_schema.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getColumnId } from '@/lib/table/column-keys'
2+
import type { ColumnDefinition } from '@/lib/table/types'
13
import type { TableGetSchemaParams, TableGetSchemaResponse } from '@/tools/table/types'
24
import type { ToolConfig } from '@/tools/types'
35

@@ -35,11 +37,21 @@ export const tableGetSchemaTool: ToolConfig<TableGetSchemaParams, TableGetSchema
3537
const result = await response.json()
3638
const data = result.data || result
3739

40+
// Always surface a usable `id` per column. Legacy columns predating the id
41+
// backfill have no stored id; their storage key is the name, so project that
42+
// as the id rather than leaving it undefined.
43+
const columns: ColumnDefinition[] = (
44+
(data.table.schema.columns ?? []) as ColumnDefinition[]
45+
).map((col) => ({ ...col, id: getColumnId(col) }))
46+
3847
return {
3948
success: true,
4049
output: {
4150
name: data.table.name,
42-
columns: data.table.schema.columns,
51+
columns,
52+
columnCount: columns.length,
53+
rowCount: data.table.rowCount ?? 0,
54+
maxRows: data.table.maxRows ?? 0,
4355
message: data.message || 'Schema retrieved successfully',
4456
},
4557
}
@@ -48,7 +60,13 @@ export const tableGetSchemaTool: ToolConfig<TableGetSchemaParams, TableGetSchema
4860
outputs: {
4961
success: { type: 'boolean', description: 'Whether schema was retrieved' },
5062
name: { type: 'string', description: 'Table name' },
51-
columns: { type: 'array', description: 'Column definitions' },
63+
columns: { type: 'array', description: 'Column definitions (each includes its stable id)' },
64+
columnCount: { type: 'number', description: 'Number of columns' },
65+
rowCount: { type: 'number', description: 'Number of rows in the table' },
66+
maxRows: {
67+
type: 'number',
68+
description: "Max rows per table for the workspace's plan",
69+
},
5270
message: { type: 'string', description: 'Status message' },
5371
},
5472
}

apps/sim/tools/table/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ export interface TableGetSchemaResponse extends ToolResponse {
142142
output: {
143143
name: string
144144
columns: ColumnDefinition[]
145+
columnCount: number
146+
rowCount: number
147+
maxRows: number
145148
message: string
146149
}
147150
}

0 commit comments

Comments
 (0)