feat(tables): stable column ids for metadata-only rename (#4898)

* feat(tables): stable column ids for metadata-only rename

* fix(tables): address review — id-key exec clears + waiting labels, name in upsert error, un-gate group output ids

* fix(tables): id-correct column undo (rename/create/delete) with id reuse on re-add

* refactor(tables): mint column ids via generateId (uuid), drop collision-check plumbing

* fix(tables): match column id or name when removing column from optimistic delete cache

* fix(tables): resolve column storage id once for delete optimistic strips; biome-format 0228 snapshot

* fix(tables): make in-grid find id-native (scan/return JSONB keys as column ids)

* fix(tables): translate id→name in CSV/JSON export read path (+regression test)
This commit is contained in:
Theodore Li
2026-06-08 23:33:52 -07:00
committed by GitHub
parent efeacb9e22
commit f7811f8acc
44 changed files with 17782 additions and 568 deletions
@@ -0,0 +1,95 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'
const { mockCheckAccess, mockQueryRows } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockQueryRows: vi.fn(),
}))
vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json({ error: 'Access denied' }, { status: result.status }),
}
})
vi.mock('@/lib/table/service', () => ({
queryRows: mockQueryRows,
}))
import { GET } from '@/app/api/table/[tableId]/export/route'
/** Table with an id-native column whose stable id (`col_email`) differs from its display name. */
function buildTable(): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: {
columns: [
{ id: 'col_email', name: 'email', type: 'string' },
{ name: 'legacy', type: 'string' }, // legacy: id == name
],
},
metadata: null,
rowCount: 1,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
}
function callGet(format: string) {
const req = new NextRequest(`http://localhost:3000/api/table/tbl_1/export?format=${format}`, {
method: 'GET',
})
return GET(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
}
describe('table export route — id→name translation', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
// Row data is keyed by stable column id (`col_email`), not the display name.
mockQueryRows.mockResolvedValue({
rows: [{ id: 'r1', data: { col_email: 'a@b.c', legacy: 'x' }, executions: {}, position: 0 }],
rowCount: 1,
totalCount: 1,
limit: 1000,
offset: 0,
})
})
it('CSV: header uses display names and cell values resolve from id-keyed data', async () => {
const res = await callGet('csv')
expect(res.status).toBe(200)
const body = await res.text()
const [header, firstRow] = body.trim().split('\n')
expect(header).toBe('email,legacy')
// Without id→name resolution the email cell would be blank.
expect(firstRow).toBe('a@b.c,x')
})
it('JSON: keys are display names, never the stable column id', async () => {
const res = await callGet('json')
expect(res.status).toBe(200)
const parsed = JSON.parse(await res.text())
expect(parsed).toEqual([{ email: 'a@b.c', legacy: 'x' }])
expect(JSON.stringify(parsed)).not.toContain('col_email')
})
})
@@ -5,6 +5,7 @@ import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
import { queryRows } from '@/lib/table/service'
import { accessError, checkAccess } from '@/app/api/table/utils'
@@ -45,6 +46,9 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
const { table } = access
const columns = table.schema.columns
// Stored row data is id-keyed; CSV headers and JSON keys are display names, so
// translate id → name on the way out (export is a name-friendly boundary).
const nameById = buildNameById(table.schema)
const safeName = sanitizeFilename(table.name)
const filename = `${safeName}.${format}`
@@ -71,12 +75,14 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
for (const row of result.rows) {
if (format === 'csv') {
const values = columns.map((c) => formatCsvValue(row.data[c.name]))
const values = columns.map((c) => formatCsvValue(row.data[getColumnId(c)]))
controller.enqueue(encoder.encode(`${toCsvRow(values)}\n`))
} else {
const prefix = firstJsonRow ? '' : ','
firstJsonRow = false
controller.enqueue(encoder.encode(prefix + JSON.stringify({ ...row.data })))
controller.enqueue(
encoder.encode(prefix + JSON.stringify(rowDataIdToName(row.data, nameById)))
)
}
}
@@ -393,10 +393,14 @@ describe('POST /api/table/[tableId]/import', () => {
)
expect(response.status).toBe(200)
expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'email', type: 'string' }),
])
// Existing columns have no id (legacy) → keyed by name; the new `email`
// column was assigned id `col_deadbeefcafef00d` (mocked generateId).
expect(appendRows()).toEqual([
{ name: 'Alice', age: 30, email: 'a@x.io' },
{ name: 'Bob', age: 40, email: 'b@x.io' },
{ name: 'Alice', age: 30, col_deadbeefcafef00d: 'a@x.io' },
{ name: 'Bob', age: 40, col_deadbeefcafef00d: 'b@x.io' },
])
})
@@ -408,7 +412,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
expect(response.status).toBe(200)
expect(appendAdditions()).toEqual([{ name: 'score', type: 'number' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'score', type: 'number' }),
])
})
it('dedupes when sanitized name collides with an existing column', async () => {
@@ -431,7 +437,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
expect(response.status).toBe(200)
expect(appendAdditions()).toEqual([{ name: 'Email_2', type: 'string' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'Email_2', type: 'string' }),
])
})
it('returns 400 when createColumns references a header not in the CSV', async () => {
@@ -494,7 +502,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
// Route forwarded the column addition into the (now atomic) import op.
expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
expect(appendAdditions()).toEqual([
expect.objectContaining({ name: 'email', type: 'string' }),
])
expect(response.status).toBe(400)
const data = await response.json()
expect(data.success).toBeUndefined()
@@ -24,6 +24,7 @@ import {
coerceRowsForTable,
createCsvParser,
dispatchAfterBatchInsert,
generateColumnId,
importAppendRows,
importReplaceRows,
inferColumnType,
@@ -176,7 +177,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema)
let prospectiveTable: TableDefinition = table
const additions: { name: string; type: string }[] = []
const additions: { id?: string; name: string; type: string }[] = []
if (createColumns && createColumns.length > 0) {
const headerSet = new Set(headers)
@@ -204,8 +205,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
}
usedNames.add(columnName.toLowerCase())
const inferredType = inferColumnType(rows.map((r) => r[header]))
additions.push({ name: columnName, type: inferredType })
// Pre-assign the id so the prospective schema (used to coerce rows) and
// the persisted column (created in importAppendRows) share the same key.
const id = generateColumnId()
additions.push({ id, name: columnName, type: inferredType })
newColumns.push({
id,
name: columnName,
type: inferredType as TableSchema['columns'][number]['type'],
required: false,
+3 -1
View File
@@ -137,7 +137,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
},
requestId
)
return { table, schema, headerToColumn: inferred.headerToColumn }
// Coerce against the *created* schema so rows key by the ids `createTable`
// assigned (the local `schema` is the id-less inferred one).
return { table, schema: table.schema, headerToColumn: inferred.headerToColumn }
}
let state: ImportState | null = null
+3
View File
@@ -200,6 +200,9 @@ export const DeleteColumnSchema = deleteTableColumnBodySchema
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
// Preserve the stable column id — it's the row-data storage key, so dropping
// it makes clients fall back to `name` and miss id-keyed cell values.
...(col.id ? { id: col.id } : {}),
name: col.name,
type: col.type,
required: col.required ?? false,
@@ -12,8 +12,14 @@ import {
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData } from '@/lib/table'
import { updateRow } from '@/lib/table'
import type { RowData, TableSchema } from '@/lib/table'
import {
buildIdByName,
buildNameById,
rowDataIdToName,
rowDataNameToId,
updateRow,
} from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
@@ -81,12 +87,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
const nameById = buildNameById(result.table.schema as TableSchema)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
data: rowDataIdToName(row.data as RowData, nameById),
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
@@ -129,11 +136,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const updatedRow = await updateRow(
{
tableId,
rowId,
data: validated.data as RowData,
data: rowDataNameToId(validated.data as RowData, idByName),
workspaceId: validated.workspaceId,
},
table,
@@ -153,7 +162,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
data: {
row: {
id: updatedRow.id,
data: updatedRow.data,
data: rowDataIdToName(updatedRow.data, nameById),
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
@@ -18,9 +18,15 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Filter, RowData, TableSchema } from '@/lib/table'
import {
batchInsertRows,
buildIdByName,
buildNameById,
deleteRowsByFilter,
deleteRowsByIds,
filterNamesToIds,
insertRow,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
updateRowsByFilter,
validateBatchRows,
validateRowData,
@@ -59,8 +65,13 @@ async function handleBatchInsert(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// External callers key row data by column name; storage keys by id.
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName))
const validation = await validateBatchRows({
rows: validated.rows as RowData[],
rows,
schema: table.schema as TableSchema,
tableId,
})
@@ -70,7 +81,7 @@ async function handleBatchInsert(
const insertedRows = await batchInsertRows(
{
tableId,
rows: validated.rows as RowData[],
rows,
workspaceId: validated.workspaceId,
userId,
},
@@ -83,7 +94,7 @@ async function handleBatchInsert(
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
data: rowDataIdToName(r.data, nameById),
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
@@ -150,11 +161,19 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// Translate name-keyed filter/sort fields → column ids; translate rows back.
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const filter = validated.filter
? filterNamesToIds(validated.filter as Filter, idByName)
: undefined
const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined
const result = await queryRows(
table,
{
filter: validated.filter as Filter | undefined,
sort: validated.sort,
filter,
sort,
limit: validated.limit,
offset: validated.offset,
includeTotal: validated.includeTotal,
@@ -168,7 +187,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
data: {
rows: result.rows.map((r) => ({
id: r.id,
data: r.data,
data: rowDataIdToName(r.data, nameById),
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
@@ -229,7 +248,9 @@ export const POST = withRouteHandler(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const rowData = validated.data as RowData
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const rowData = rowDataNameToId(validated.data as RowData, idByName)
const validation = await validateRowData({
rowData,
@@ -254,7 +275,7 @@ export const POST = withRouteHandler(
data: {
row: {
id: row.id,
data: row.data,
data: rowDataIdToName(row.data, nameById),
position: row.position,
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
@@ -312,7 +333,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const sizeValidation = validateRowSize(validated.data as RowData)
const idByName = buildIdByName(table.schema as TableSchema)
const patchData = rowDataNameToId(validated.data as RowData, idByName)
const sizeValidation = validateRowSize(patchData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Validation error', details: sizeValidation.errors },
@@ -323,8 +347,8 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
const result = await updateRowsByFilter(
table,
{
filter: validated.filter as Filter,
data: validated.data as RowData,
filter: filterNamesToIds(validated.filter as Filter, idByName),
data: patchData,
limit: validated.limit,
},
requestId
@@ -424,10 +448,11 @@ export const DELETE = withRouteHandler(
})
}
const idByName = buildIdByName(table.schema as TableSchema)
const result = await deleteRowsByFilter(
table,
{
filter: validated.filter as Filter,
filter: filterNamesToIds(validated.filter as Filter, idByName),
limit: validated.limit,
},
requestId
@@ -5,8 +5,14 @@ import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables'
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData } from '@/lib/table'
import { upsertRow } from '@/lib/table'
import type { RowData, TableSchema } from '@/lib/table'
import {
buildIdByName,
buildNameById,
rowDataIdToName,
rowDataNameToId,
upsertRow,
} from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
@@ -51,11 +57,13 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const upsertResult = await upsertRow(
{
tableId,
workspaceId: validated.workspaceId,
data: validated.data as RowData,
data: rowDataNameToId(validated.data as RowData, idByName),
userId,
conflictTarget: validated.conflictTarget,
},
@@ -68,7 +76,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
data: {
row: {
id: upsertResult.row.id,
data: upsertResult.row.data,
data: rowDataIdToName(upsertResult.row.data, nameById),
createdAt:
upsertResult.row.createdAt instanceof Date
? upsertResult.row.createdAt.toISOString()
@@ -116,7 +116,9 @@ function ColumnConfigBody({
return
}
const renamed = trimmedName !== config.columnName
// `config.columnName` is the column id; compare against the current display
// name to detect an actual rename.
const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput
@@ -49,11 +49,11 @@ export function ExpandedCellPopover({
// workflow columns share `name` across siblings, so prefer `key` when set.
const matchByKey = expandedCell.columnKey
? (c: DisplayColumn) => c.key === expandedCell.columnKey
: (c: DisplayColumn) => c.name === expandedCell.columnName
: (c: DisplayColumn) => c.key === expandedCell.columnName
const column = columns.find(matchByKey)
if (!row || !column) return null
const colIndex = columns.findIndex(matchByKey)
return { row, column, colIndex, value: row.data[column.name] }
return { row, column, colIndex, value: row.data[column.key] }
}, [expandedCell, rows, columns])
const isBooleanCell = target?.column.type === 'boolean'
@@ -142,7 +142,7 @@ export function ExpandedCellPopover({
// Fall back to the raw draft for non-date columns, matching the inline editor.
const raw = displayToStorage(draftValue) ?? draftValue
const cleaned = cleanCellValue(raw, target.column)
onSave(target.row.id, target.column.name, cleaned, 'blur')
onSave(target.row.id, target.column.key, cleaned, 'blur')
onClose()
}
@@ -190,6 +190,8 @@ export const DataRow = React.memo(function DataRow({
*/
const waitingByGroupId = React.useMemo(() => {
if (workflowGroups.length === 0) return null
// Deps are stored as column ids; the "Waiting on …" pill shows display names.
const nameByColumnId = new Map(columns.map((c) => [c.key, c.name]))
const map = new Map<string, string[]>()
for (const group of workflowGroups) {
// autoRun=false groups never fire from the scheduler — there's nothing
@@ -197,10 +199,13 @@ export const DataRow = React.memo(function DataRow({
if (group.autoRun === false) continue
const unmet = getUnmetGroupDeps(group, row)
if (unmet.columns.length === 0) continue
map.set(group.id, unmet.columns)
map.set(
group.id,
unmet.columns.map((id) => nameByColumnId.get(id) ?? id)
)
}
return map
}, [workflowGroups, row])
}, [workflowGroups, row, columns])
const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol)
const isRowSelected = isRowChecked
/**
@@ -294,7 +299,7 @@ export const DataRow = React.memo(function DataRow({
colIndex >= sel.startCol &&
colIndex <= sel.endCol
const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol
const isEditing = editingColumnName === column.name
const isEditing = editingColumnName === column.key
const isHighlighted = inRange || isRowChecked
const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked
@@ -325,13 +330,13 @@ export const DataRow = React.memo(function DataRow({
}}
onMouseEnter={() => onCellMouseEnter(rowIndex, colIndex)}
onClick={(e) =>
onClick(row.id, column.name, {
onClick(row.id, column.key, {
toggleBoolean:
!e.shiftKey &&
Boolean((e.target as HTMLElement).closest('[data-boolean-cell-toggle]')),
})
}
onDoubleClick={() => onDoubleClick(row.id, column.name, column.key)}
onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)}
>
{isHighlighted && (isMultiCell || isRowChecked) && (
<div
@@ -360,9 +365,9 @@ export const DataRow = React.memo(function DataRow({
<CellContent
workspaceId={workspaceId}
value={
pendingCellValue && column.name in pendingCellValue
? pendingCellValue[column.name]
: row.data[column.name]
pendingCellValue && column.key in pendingCellValue
? pendingCellValue[column.key]
: row.data[column.key]
}
exec={resolveCellExec(
row,
@@ -374,7 +379,7 @@ export const DataRow = React.memo(function DataRow({
column={column}
isEditing={isEditing}
initialCharacter={isEditing ? initialCharacter : undefined}
onSave={(value, reason) => onSave(row.id, column.name, value, reason)}
onSave={(value, reason) => onSave(row.id, column.key, value, reason)}
onCancel={onCancel}
waitingOnLabels={
column.workflowGroupId
@@ -153,7 +153,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
}
didDragRef.current = true
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', column.name)
e.dataTransfer.setData('text/plain', column.key)
// Workflow-output columns drag as a whole group, so the ghost shows
// the group's name (falling back to the workflow's name, then the
@@ -168,9 +168,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost))
onDragStart?.(column.name)
onDragStart?.(column.key)
},
[column.name, ownGroup, configuredWorkflow, readOnly, isRenaming, onDragStart]
[column.key, column.name, ownGroup, configuredWorkflow, readOnly, isRenaming, onDragStart]
)
const handleDragOver = useCallback(
@@ -180,9 +180,9 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const midX = rect.left + rect.width / 2
const side = e.clientX < midX ? 'left' : 'right'
onDragOver?.(column.name, side)
onDragOver?.(column.key, side)
},
[column.name, onDragOver]
[column.key, onDragOver]
)
const handleDrop = useCallback((e: React.DragEvent) => {
@@ -217,7 +217,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
if (isRenaming) return
onColumnSelect(colIndex, e.shiftKey)
if (!e.shiftKey) {
onOpenConfig(column.name)
onOpenConfig(column.key)
}
}
@@ -162,28 +162,28 @@ export function ColumnOptionsMenu({
View workflow
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onOpenConfig(column.name)}>
<DropdownMenuItem onSelect={() => onOpenConfig(column.key)}>
<Pencil />
Edit column
</DropdownMenuItem>
{onPinToggle && (
<DropdownMenuItem onSelect={() => onPinToggle(column.name)}>
<DropdownMenuItem onSelect={() => onPinToggle(column.key)}>
{isPinned ? <PinOff /> : <Pin />}
{isPinned ? 'Unpin column' : 'Pin column'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onInsertLeft(column.name)}>
<DropdownMenuItem onSelect={() => onInsertLeft(column.key)}>
<ArrowLeft />
Insert column left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onInsertRight(column.name)}>
<DropdownMenuItem onSelect={() => onInsertRight(column.key)}>
<ArrowRight />
Insert column right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => (onDeleteGroup ? onDeleteGroup() : onDeleteColumn(column.name))}
onSelect={() => (onDeleteGroup ? onDeleteGroup() : onDeleteColumn(column.key))}
>
{deleteLabel === 'Hide column' ? <EyeOff /> : <Trash />}
{deleteLabel ?? 'Delete column'}
@@ -12,6 +12,7 @@ import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tabl
import { cn } from '@/lib/core/utils/cn'
import { captureEvent } from '@/lib/posthog/client'
import type { ColumnDefinition, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -474,38 +475,12 @@ export function TableGrid({
setColumnOrder(order)
}
// Width keys are either the logical name or `${name}::${path}` for fanned-out
// workflow columns; rename must rewrite every key whose prefix matches.
function handleColumnRename(oldName: string, newName: string) {
let updatedWidths = columnWidthsRef.current
let widthsChanged = false
const nextWidths: Record<string, number> = {}
for (const [key, width] of Object.entries(updatedWidths)) {
if (key === oldName) {
nextWidths[newName] = width
widthsChanged = true
} else if (key.startsWith(`${oldName}::`)) {
nextWidths[`${newName}${key.slice(oldName.length)}`] = width
widthsChanged = true
} else {
nextWidths[key] = width
}
}
if (widthsChanged) {
updatedWidths = nextWidths
setColumnWidths(updatedWidths)
}
const updatedOrder = columnOrderRef.current?.map((n) => (n === oldName ? newName : n))
if (updatedOrder) setColumnOrder(updatedOrder)
const updatedPinned = pinnedColumnsRef.current.map((n) => (n === oldName ? newName : n))
const pinnedChanged = updatedPinned.some((n, i) => n !== pinnedColumnsRef.current[i])
if (pinnedChanged) setPinnedColumns(updatedPinned)
updateMetadataRef.current({
columnWidths: updatedWidths,
...(updatedOrder ? { columnOrder: updatedOrder } : {}),
...(pinnedChanged ? { pinnedColumns: updatedPinned } : {}),
})
}
// Column width/order/pin state is keyed by stable column id, so a rename
// changes no keys — it's a no-op here. The new display name flows in from the
// schema query cache (the rename mutation patches it optimistically and
// invalidates), and headers re-render from `column.name`. Kept as a stable
// sink for the undo system and config sidebars.
function handleColumnRename(_oldName: string, _newName: string) {}
// Populate the wrapper's sink so its sidebars can fire renames back into
// the grid. Reads through refs, so identity stability isn't required.
columnRenameSinkRef.current = handleColumnRename
@@ -527,16 +502,14 @@ export function TableGrid({
return pinnedColumnsRef.current
}
const handlePinToggle = useCallback((columnName: string) => {
const col = columnsRef.current.find((c) => c.name === columnName)
const handlePinToggle = useCallback((columnId: string) => {
const col = columnsRef.current.find((c) => getColumnId(c) === columnId)
const siblings: string[] = col?.workflowGroupId
? columnsRef.current
.filter((c) => c.workflowGroupId === col.workflowGroupId)
.map((c) => c.name)
: [columnName]
? columnsRef.current.filter((c) => c.workflowGroupId === col.workflowGroupId).map(getColumnId)
: [columnId]
const current = pinnedColumnsRef.current
const newPinned = current.includes(columnName)
const newPinned = current.includes(columnId)
? current.filter((n) => !siblings.includes(n))
: [...current, ...siblings.filter((n) => !current.includes(n))]
setPinnedColumns(newPinned)
@@ -547,7 +520,7 @@ export function TableGrid({
// entry). On unpin we must re-sort so the unpinned column doesn't stay
// sandwiched between still-pinned siblings, which would render the sticky
// zone with a gap.
const currentOrder = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name)
const currentOrder = columnOrderRef.current ?? schemaColumnsRef.current.map(getColumnId)
const pinnedSet = new Set(newPinned)
const newOrder = [
...currentOrder.filter((n) => pinnedSet.has(n)),
@@ -587,13 +560,13 @@ export function TableGrid({
if (!columnOrder || columnOrder.length === 0) {
ordered = columns
} else {
const colMap = new Map(columns.map((c) => [c.name, c]))
const colMap = new Map(columns.map((c) => [getColumnId(c), c]))
ordered = []
for (const name of columnOrder) {
const col = colMap.get(name)
for (const id of columnOrder) {
const col = colMap.get(id)
if (col) {
ordered.push(col)
colMap.delete(name)
colMap.delete(id)
}
}
for (const col of colMap.values()) {
@@ -621,7 +594,7 @@ export function TableGrid({
// Used as the sole dep that ties pinnedOffsets to column-width changes so
// that unpinned resizes don't recreate the Map and re-render all DataRows.
const pinnedWidthsKey = displayColumns
.filter((c) => pinnedColumnSet.has(c.name))
.filter((c) => pinnedColumnSet.has(c.key))
.map((c) => columnWidths[c.key] ?? COL_WIDTH)
.join(',')
@@ -631,7 +604,7 @@ export function TableGrid({
let left = checkboxColWidth
const widths = columnWidthsRef.current
for (const col of displayColumns) {
if (pinnedColumnSet.has(col.name)) {
if (pinnedColumnSet.has(col.key)) {
offsets.set(col.key, left)
left += widths[col.key] ?? COL_WIDTH
}
@@ -642,7 +615,7 @@ export function TableGrid({
const lastPinnedColKey = useMemo<string | null>(() => {
let last: string | null = null
for (const col of displayColumns) {
if (pinnedColumnSet.has(col.name)) last = col.key
if (pinnedColumnSet.has(col.key)) last = col.key
}
return last
}, [displayColumns, pinnedColumnSet])
@@ -694,8 +667,8 @@ export function TableGrid({
// share the same `name`. Compute the group's left edge and total width by
// accumulating across siblings.
const cols = displayColumns
const dragGroup = cols.findIndex((c) => c.name === dragColumnName)
const targetGroupStart = cols.findIndex((c) => c.name === dropTargetColumnName)
const dragGroup = cols.findIndex((c) => c.key === dragColumnName)
const targetGroupStart = cols.findIndex((c) => c.key === dropTargetColumnName)
if (dragGroup === -1 || targetGroupStart === -1) return null
const dragGroupSize = cols[dragGroup].groupSize
@@ -789,13 +762,15 @@ export function TableGrid({
const findMatches = useMemo<readonly TableFindMatch[]>(() => {
const raw = findData?.matches
if (!raw || raw.length === 0) return EMPTY_FIND_MATCHES
const colIndexByName = new Map(displayColumns.map((c, i) => [c.name, i]))
// `m.column` is the stable column id (the JSONB storage key); index display
// columns by their id so id-native tables resolve and stale/hidden columns drop.
const colIndexByKey = new Map(displayColumns.map((c, i) => [c.key, i]))
return raw
.filter((m) => colIndexByName.has(m.column))
.filter((m) => colIndexByKey.has(m.column))
.sort(
(a, b) =>
a.ordinal - b.ordinal ||
(colIndexByName.get(a.column) ?? 0) - (colIndexByName.get(b.column) ?? 0)
(colIndexByKey.get(a.column) ?? 0) - (colIndexByKey.get(b.column) ?? 0)
)
}, [findData, displayColumns])
@@ -836,7 +811,7 @@ export function TableGrid({
if (!match) return
const rowIndex = rows.findIndex((r) => r.id === match.rowId)
if (rowIndex === -1) return
const colIndex = displayColumns.findIndex((c) => c.name === match.column)
const colIndex = displayColumns.findIndex((c) => c.key === match.column)
pendingMatchRef.current = null
if (colIndex === -1) return
setEditingCell(null)
@@ -873,8 +848,11 @@ export function TableGrid({
}, [])
const columnRename = useInlineRename({
// `columnName` is the column id; record the prior display name + id so undo
// restores the label (not the id) and targets the right column.
onSave: (columnName, newName) => {
pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName })
const oldName = columnsRef.current.find((c) => c.key === columnName)?.name ?? columnName
pushUndoRef.current({ type: 'rename-column', oldName, newName, columnId: columnName })
handleColumnRename(columnName, newName)
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
},
@@ -897,7 +875,7 @@ export function TableGrid({
function handleContextMenuEditCell() {
if (contextMenu.row && contextMenu.columnName) {
const column = columnsRef.current.find((c) => c.name === contextMenu.columnName)
const column = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName)
if (column?.type === 'boolean') {
toggleBooleanCell(
contextMenu.row.id,
@@ -996,7 +974,7 @@ export function TableGrid({
// cascade re-runs dependents on its own) instead of every group on the row.
let contextMenuGroupId: string | null = null
if (contextMenu.row && contextMenu.columnName) {
const _col = columnsRef.current.find((c) => c.name === contextMenu.columnName)
const _col = columnsRef.current.find((c) => getColumnId(c) === contextMenu.columnName)
const _gid = _col?.workflowGroupId
if (_col && _gid) {
const _exec = contextMenu.row.executions?.[_gid]
@@ -1105,7 +1083,7 @@ export function TableGrid({
const colIndex = Number.parseInt(td.getAttribute('data-col') || '-1', 10)
if (rowIndex >= 0 && colIndex >= 0) {
columnName =
colIndex < columnsRef.current.length ? columnsRef.current[colIndex].name : null
colIndex < columnsRef.current.length ? columnsRef.current[colIndex].key : null
const sel = computeNormalizedSelection(
selectionAnchorRef.current,
@@ -1344,7 +1322,7 @@ export function TableGrid({
measure.className = 'text-small'
for (const row of currentRows) {
const val = row.data[column.name]
const val = row.data[column.key]
if (val == null) continue
let text: string
if (column.type === 'json') {
@@ -1387,14 +1365,14 @@ export function TableGrid({
const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => {
const dragged = dragColumnNameRef.current
const cols = schemaColumnsRef.current
const targetCol = cols.find((c) => c.name === columnName)
const targetCol = cols.find((c) => getColumnId(c) === columnName)
const targetGid = targetCol?.workflowGroupId
// Suppress drop targeting while hovering siblings of the dragged column's
// own group: reordering inside a group is meaningless (the group renders
// as a unit) and the chasing indicator just flickers.
if (dragged) {
const draggedGid = cols.find((c) => c.name === dragged)?.workflowGroupId
const draggedGid = cols.find((c) => getColumnId(c) === dragged)?.workflowGroupId
if (draggedGid && draggedGid === targetGid) {
if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null)
return
@@ -1441,9 +1419,9 @@ export function TableGrid({
// missing — append any unknown schema names so the dragged column is
// always indexable. The next reorder write persists the reconciled
// list, healing the table going forward.
const persisted = columnOrderRef.current ?? schemaCols.map((c) => c.name)
const persisted = columnOrderRef.current ?? schemaCols.map(getColumnId)
const known = new Set(persisted)
const missing = schemaCols.map((c) => c.name).filter((n) => !known.has(n))
const missing = schemaCols.map(getColumnId).filter((n) => !known.has(n))
const currentOrder = missing.length > 0 ? [...persisted, ...missing] : persisted
// Group-aware reorder: a workflow group's outputs must stay contiguous in
@@ -1451,7 +1429,7 @@ export function TableGrid({
// save). So we treat the entire group as the unit being moved when the
// dragged column belongs to one, and snap the drop position to the
// outside edge of any group the target belongs to.
const colByName = new Map(schemaCols.map((c) => [c.name, c]))
const colByName = new Map(schemaCols.map((c) => [getColumnId(c), c]))
const draggedGid = colByName.get(dragged)?.workflowGroupId
const orderIndex = new Map<string, number>()
@@ -1579,7 +1557,7 @@ export function TableGrid({
const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft
const cols = columnsRef.current
const draggedGid = cols.find((c) => c.name === dragColumnNameRef.current)?.workflowGroupId
const draggedGid = cols.find((c) => c.key === dragColumnNameRef.current)?.workflowGroupId
let left = checkboxColWidth
let i = 0
while (i < cols.length) {
@@ -1600,14 +1578,14 @@ export function TableGrid({
}
const pinned = pinnedColumnsRef.current
const draggedName = dragColumnNameRef.current
if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.name)) {
if (draggedName && pinned.includes(draggedName) !== pinned.includes(col.key)) {
if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null)
return
}
const midX = left + groupWidth / 2
const side = cursorX < midX ? 'left' : 'right'
if (col.name !== dropTargetColumnNameRef.current || side !== dropSideRef.current) {
setDropTargetColumnName(col.name)
if (col.key !== dropTargetColumnNameRef.current || side !== dropSideRef.current) {
setDropTargetColumnName(col.key)
setDropSide(side)
}
return
@@ -1862,7 +1840,7 @@ export function TableGrid({
} else if (rect.bottom > view.bottom) {
scrollEl.scrollTop += rect.bottom - view.bottom
}
const targetColName = columnsRef.current[colIndex]?.name
const targetColName = columnsRef.current[colIndex]?.key
const targetIsPinned = targetColName ? pinnedColumnSet.has(targetColName) : false
if (!targetIsPinned) {
if (rect.left < view.left + pinnedStickyLeftEdge) {
@@ -1902,7 +1880,7 @@ export function TableGrid({
const handleCellClick = useCallback(
(rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => {
const column = columnsRef.current.find((c) => c.name === columnName)
const column = columnsRef.current.find((c) => c.key === columnName)
if (column?.type === 'boolean') {
if (!options?.toggleBoolean || !canEditRef.current) return
const row = rowsRef.current.find((r) => r.id === rowId)
@@ -2072,8 +2050,8 @@ export function TableGrid({
const updates: Record<string, unknown> = {}
const previousData: Record<string, unknown> = {}
for (const col of currentCols) {
previousData[col.name] = row.data[col.name] ?? null
updates[col.name] = null
previousData[col.key] = row.data[col.key] ?? null
updates[col.key] = null
}
undoCells.push({ rowId: row.id, data: previousData })
batchUpdates.push({ rowId: row.id, data: updates })
@@ -2129,10 +2107,10 @@ export function TableGrid({
if (!row) return
if (col.type === 'boolean') {
toggleBooleanCellRef.current(row.id, col.name, row.data[col.name])
toggleBooleanCellRef.current(row.id, col.key, row.data[col.key])
return
}
setEditingCell({ rowId: row.id, columnName: col.name })
setEditingCell({ rowId: row.id, columnName: col.key })
setInitialCharacter(null)
return
}
@@ -2266,7 +2244,7 @@ export function TableGrid({
const newData: Record<string, unknown> = {}
for (let c = sel.startCol; c <= sel.endCol; c++) {
if (c < cols.length) {
const colName = cols[c].name
const colName = cols[c].key
oldData[colName] = row.data[colName] ?? null
newData[colName] = sourceRow.data[colName] ?? null
}
@@ -2299,7 +2277,7 @@ export function TableGrid({
const updates: Record<string, unknown> = {}
const previousData: Record<string, unknown> = {}
for (let c = sel.startCol; c <= sel.endCol; c++) {
const colName = cols[c]?.name
const colName = cols[c]?.key
if (!colName) continue
previousData[colName] = row.data[colName] ?? null
updates[colName] = null
@@ -2325,7 +2303,7 @@ export function TableGrid({
const previousData: Record<string, unknown> = {}
for (let c = sel.startCol; c <= sel.endCol; c++) {
if (c < cols.length) {
const colName = cols[c].name
const colName = cols[c].key
previousData[colName] = row.data[colName] ?? null
updates[colName] = null
}
@@ -2356,7 +2334,7 @@ export function TableGrid({
const row = currentRows[anchor.rowIndex]
if (!row) return
setEditingCell({ rowId: row.id, columnName: col.name })
setEditingCell({ rowId: row.id, columnName: col.key })
setInitialCharacter(e.key)
return
}
@@ -2496,7 +2474,7 @@ export function TableGrid({
? () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS)
: async () => ({ rows: rowsRef.current, hasMore: false }),
selectRow: (row) => rowSelectionIncludes(rowSel, row.id),
buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])),
buildCells: (row) => cols.map((col) => cellToText(row.data[col.key])),
verb: 'Copied',
estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current,
})
@@ -2514,7 +2492,7 @@ export function TableGrid({
if (isColumnSelectionRef.current) {
const colNames: string[] = []
for (let c = sel.startCol; c <= sel.endCol; c++) {
const name = cols[c]?.name
const name = cols[c]?.key
if (name) colNames.push(name)
}
writeSelectionToClipboard({
@@ -2533,7 +2511,7 @@ export function TableGrid({
for (let c = sel.startCol; c <= sel.endCol; c++) {
if (c >= cols.length) break
const row = currentRows[r]
cells.push(row ? cellToText(row.data[cols[c].name]) : '')
cells.push(row ? cellToText(row.data[cols[c].key]) : '')
}
lines.push(cells.join('\t'))
}
@@ -2558,13 +2536,13 @@ export function TableGrid({
? () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS)
: async () => ({ rows: rowsRef.current, hasMore: false }),
selectRow: (row) => rowSelectionIncludes(rowSel, row.id),
buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])),
buildCells: (row) => cols.map((col) => cellToText(row.data[col.key])),
verb: 'Cut',
estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current,
afterCopy: (copied) =>
clearCutRows(
copied,
cols.map((c) => c.name)
cols.map((c) => c.key)
),
})
return
@@ -2581,7 +2559,7 @@ export function TableGrid({
if (isColumnSelectionRef.current) {
const colNames: string[] = []
for (let c = sel.startCol; c <= sel.endCol; c++) {
const name = cols[c]?.name
const name = cols[c]?.key
if (name) colNames.push(name)
}
writeSelectionToClipboard({
@@ -2606,7 +2584,7 @@ export function TableGrid({
const previousData: Record<string, unknown> = {}
for (let c = sel.startCol; c <= sel.endCol; c++) {
if (c < cols.length) {
const colName = cols[c].name
const colName = cols[c].key
cells.push(cellToText(row.data[colName]))
previousData[colName] = row.data[colName] ?? null
updates[colName] = null
@@ -2664,7 +2642,7 @@ export function TableGrid({
const targetCol = currentAnchor.colIndex + c
if (targetCol >= currentCols.length) break
try {
rowData[currentCols[targetCol].name] = cleanCellValue(
rowData[currentCols[targetCol].key] = cleanCellValue(
pasteRows[r][c],
currentCols[targetCol]
)
@@ -2856,7 +2834,7 @@ export function TableGrid({
const insertColumnInOrder = useCallback(
(anchorColumn: string, newColumn: string, side: 'left' | 'right') => {
const order = columnOrderRef.current ?? schemaColumnsRef.current.map((c) => c.name)
const order = columnOrderRef.current ?? schemaColumnsRef.current.map(getColumnId)
const newOrder = [...order]
let anchorIdx = newOrder.indexOf(anchorColumn)
if (anchorIdx === -1) {
@@ -2875,16 +2853,22 @@ export function TableGrid({
)
const handleInsertColumnLeft = useCallback(
(columnName: string) => {
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
(columnId: string) => {
const index = schemaColumnsRef.current.findIndex((c) => getColumnId(c) === columnId)
if (index === -1) return
const name = generateColumnName()
addColumnMutation.mutate(
{ name, type: 'string', position: index },
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position: index })
insertColumnInOrder(columnName, name, 'left')
onSuccess: (result) => {
const newId = result.data.columns.find((c) => c.name === name)?.id ?? name
pushUndoRef.current({
type: 'create-column',
columnName: name,
columnId: newId,
position: index,
})
insertColumnInOrder(columnId, newId, 'left')
},
}
)
@@ -2893,17 +2877,23 @@ export function TableGrid({
)
const handleInsertColumnRight = useCallback(
(columnName: string) => {
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
(columnId: string) => {
const index = schemaColumnsRef.current.findIndex((c) => getColumnId(c) === columnId)
if (index === -1) return
const name = generateColumnName()
const position = index + 1
addColumnMutation.mutate(
{ name, type: 'string', position },
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position })
insertColumnInOrder(columnName, name, 'right')
onSuccess: (result) => {
const newId = result.data.columns.find((c) => c.name === name)?.id ?? name
pushUndoRef.current({
type: 'create-column',
columnName: name,
columnId: newId,
position,
})
insertColumnInOrder(columnId, newId, 'right')
},
}
)
@@ -2926,7 +2916,7 @@ export function TableGrid({
const handleConfigureColumn = useCallback(
(columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
const column = columnsRef.current.find((c) => c.key === columnName)
const group = column?.workflowGroupId
? workflowGroupById.get(column.workflowGroupId)
: undefined
@@ -2971,11 +2961,11 @@ export function TableGrid({
if (isColumnSelectionRef.current && selectionAnchorRef.current) {
const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current)
if (sel && sel.startCol !== sel.endCol) {
const clickedIdx = cols.findIndex((c) => c.name === columnName)
const clickedIdx = cols.findIndex((c) => c.key === columnName)
if (clickedIdx >= sel.startCol && clickedIdx <= sel.endCol) {
const names: string[] = []
for (let c = sel.startCol; c <= sel.endCol; c++) {
if (c < cols.length) names.push(cols[c].name)
if (c < cols.length) names.push(cols[c].key)
}
if (names.length > 0) return names
}
@@ -2997,7 +2987,7 @@ export function TableGrid({
const groups = workflowGroupsRef.current
const removalsByGroup = new Map<string, Set<string>>()
for (const name of names) {
const def = schemaCols.find((c) => c.name === name)
const def = schemaCols.find((c) => getColumnId(c) === name)
if (!def?.workflowGroupId) return false
const set = removalsByGroup.get(def.workflowGroupId) ?? new Set<string>()
set.add(name)
@@ -3045,7 +3035,7 @@ export function TableGrid({
{ position: number; def: (typeof cols)[number] | undefined }
>()
for (const name of columnsToDelete) {
const def = cols.find((c) => c.name === name)
const def = cols.find((c) => getColumnId(c) === name)
originalPositions.set(name, { position: def ? cols.indexOf(def) : cols.length, def })
}
const deletedOriginalPositions: number[] = []
@@ -3068,7 +3058,9 @@ export function TableGrid({
deletedOriginalPositions.push(entry.position)
pushUndoRef.current({
type: 'delete-column',
columnName: columnToDelete,
// `columnToDelete` is the stable id; record the display name for re-create.
columnName: entry.def?.name ?? columnToDelete,
columnId: columnToDelete,
columnType: entry.def?.type ?? 'string',
columnPosition: adjustedPosition >= 0 ? adjustedPosition : cols.length,
columnUnique: entry.def?.unique ?? false,
@@ -3593,7 +3585,7 @@ export function TableGrid({
onDragLeave={
userPermissions.canEdit ? handleColumnDragLeave : undefined
}
isPinned={firstCol ? pinnedColumnSet.has(firstCol.name) : false}
isPinned={firstCol ? pinnedColumnSet.has(firstCol.key) : false}
onPinToggle={userPermissions.canEdit ? handlePinToggle : undefined}
stickyLeft={stickyLeft}
isLastPinned={lastCol?.key === lastPinnedColKey}
@@ -3629,7 +3621,7 @@ export function TableGrid({
numRegionWidth={numRegionWidth}
/>
{displayColumns.map((column, idx) => {
const colIsPinned = pinnedColumnSet.has(column.name)
const colIsPinned = pinnedColumnSet.has(column.key)
const colStickyLeft = pinnedOffsets.get(column.key)
return (
<ColumnHeaderMenu
@@ -3637,7 +3629,7 @@ export function TableGrid({
column={column}
colIndex={idx}
readOnly={!userPermissions.canEdit}
isRenaming={columnRename.editingId === column.name}
isRenaming={columnRename.editingId === column.key}
isColumnSelected={
isColumnSelection &&
normalizedSelection !== null &&
@@ -3645,7 +3637,7 @@ export function TableGrid({
idx <= normalizedSelection.endCol
}
renameValue={
columnRename.editingId === column.name ? columnRename.editValue : ''
columnRename.editingId === column.key ? columnRename.editValue : ''
}
onRenameValueChange={columnRename.setEditValue}
onRenameSubmit={columnRename.submitRename}
@@ -3664,7 +3656,7 @@ export function TableGrid({
onDragLeave={handleColumnDragLeave}
workflows={workflows}
workflowGroups={tableWorkflowGroups}
sourceInfo={columnSourceInfo.get(column.name)}
sourceInfo={columnSourceInfo.get(column.key)}
onOpenConfig={handleConfigureColumn}
onViewWorkflow={handleViewWorkflow}
isPinned={colIsPinned}
@@ -6,6 +6,7 @@ import type {
TableRow as TableRowType,
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps'
import type { DeletedRowSnapshot } from '@/stores/table/types'
import type { DisplayColumn } from './types'
@@ -109,10 +110,10 @@ export function expandToDisplayColumns(
const startIdx = out.length
for (let k = 0; k < size; k++) {
const child = columns[i + k]
const output = group?.outputs.find((o) => o.columnName === child.name)
const output = group?.outputs.find((o) => o.columnName === getColumnId(child))
out.push({
...child,
key: child.name,
key: getColumnId(child),
outputBlockId: output?.blockId,
outputPath: output?.path,
groupSize: size,
@@ -125,7 +126,7 @@ export function expandToDisplayColumns(
} else {
out.push({
...column,
key: column.name,
key: getColumnId(column),
groupSize: 1,
groupStartColIndex: out.length,
headerLabel: column.name,
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { Badge, ChipCombobox, CollapsibleCard, Label } from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import type { InputFormatField } from '@/lib/workflows/types'
interface InputMappingSectionProps {
@@ -30,7 +31,7 @@ export function InputMappingSection({
const namedFields = inputFields.filter((f): f is InputFormatField & { name: string } =>
Boolean(f.name?.trim())
)
const columns = columnOptions.map((c) => ({ label: c.name, value: c.name }))
const columns = columnOptions.map((c) => ({ label: c.name, value: getColumnId(c) }))
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const toggle = (name: string) => setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }))
@@ -2,6 +2,7 @@
import { ChipCombobox, Label } from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
interface RunSettingsSectionProps {
/** All columns the group can depend on (left-of-current scalar + workflow
@@ -26,7 +27,7 @@ export function RunSettingsSection({
onChangeDeps,
error,
}: RunSettingsSectionProps) {
const options = depOptions.map((c) => ({ label: c.name, value: c.name }))
const options = depOptions.map((c) => ({ label: c.name, value: getColumnId(c) }))
return (
<div className='flex flex-col gap-[9.5px]'>
@@ -40,6 +40,7 @@ import type {
WorkflowGroupInputMapping,
WorkflowGroupOutput,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming'
import {
type FlattenOutputsBlockInput,
@@ -270,7 +271,7 @@ export function WorkflowSidebarBody({
const existingGroup: WorkflowGroup | undefined = (() => {
if (config.mode === 'edit-group') return workflowGroups.find((g) => g.id === config.groupId)
if (config.mode === 'edit-output') {
const col = allColumns.find((c) => c.name === config.columnName)
const col = allColumns.find((c) => getColumnId(c) === config.columnName)
return col?.workflowGroupId
? workflowGroups.find((g) => g.id === col.workflowGroupId)
: undefined
@@ -279,7 +280,7 @@ export function WorkflowSidebarBody({
})()
const existingColumn =
config.mode === 'edit-output'
? (allColumns.find((c) => c.name === config.columnName) ?? null)
? (allColumns.find((c) => getColumnId(c) === config.columnName) ?? null)
: null
// `manual` vs `enrichment`. For create it's carried on the config; for edit
@@ -297,7 +298,7 @@ export function WorkflowSidebarBody({
// existing column qualifies.
const anchorIdx = (() => {
if (config.mode === 'edit-output') {
const idx = allColumns.findIndex((c) => c.name === config.columnName)
const idx = allColumns.findIndex((c) => getColumnId(c) === config.columnName)
return idx === -1 ? allColumns.length : idx
}
if (config.mode === 'edit-group' && existingGroup) {
@@ -322,8 +323,8 @@ export function WorkflowSidebarBody({
// Every left-of-current column is a valid dep — workflow output columns
// included. Exclude this group's own outputs (you can't depend on yourself).
const ownOutputNames = new Set(existingGroup?.outputs.map((o) => o.columnName) ?? [])
const depOptions = otherColumns.filter((c) => !ownOutputNames.has(c.name))
const ownOutputIds = new Set(existingGroup?.outputs.map((o) => o.columnName) ?? [])
const depOptions = otherColumns.filter((c) => !ownOutputIds.has(getColumnId(c)))
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string>(
() => existingGroup?.workflowId ?? (config.mode === 'create' ? (config.workflowId ?? '') : '')
@@ -402,7 +403,9 @@ export function WorkflowSidebarBody({
return allColumns
.filter(
(c) =>
c.name !== anchor && !c.workflowGroupId && !startBlockInputs.existingNames.has(c.name)
getColumnId(c) !== anchor &&
!c.workflowGroupId &&
!startBlockInputs.existingNames.has(c.name)
)
.map((c) => c.name)
}, [allColumns, anchorColumnName, startBlockInputs])
@@ -539,7 +542,7 @@ export function WorkflowSidebarBody({
const encoded: string[] = []
if (config.mode === 'edit-output' && existingColumn) {
// Single-output sub-mode: only seed the picker with this column's mapping.
const own = existingGroup.outputs.find((o) => o.columnName === existingColumn.name)
const own = existingGroup.outputs.find((o) => o.columnName === getColumnId(existingColumn))
if (own) {
const match = blockOutputGroups.find(
(g) => g.blockId === own.blockId && g.paths.includes(own.path)
@@ -562,13 +565,16 @@ export function WorkflowSidebarBody({
// persisted mapping yet but matches a table column by name. Runs once; never
// overrides a persisted or user-picked mapping.
if (!inputMappingsHydrated && startBlockInputs.existing.length > 0) {
const columnNames = new Set(depOptions.map((c) => c.name))
// Map a Start input field to the column sharing its name, storing the
// column id (the value the dropdowns and persisted mappings key on).
const idByColumnName = new Map(depOptions.map((c) => [c.name, getColumnId(c)]))
const next = { ...inputMappings }
let changed = false
for (const field of startBlockInputs.existing) {
if (!field.name || next[field.name]) continue
if (columnNames.has(field.name)) {
next[field.name] = field.name
const colId = idByColumnName.get(field.name)
if (colId) {
next[field.name] = colId
changed = true
}
}
@@ -9,6 +9,7 @@ import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@/component
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import { captureEvent } from '@/lib/posthog/client'
import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import {
type ColumnOption,
ResourceHeader,
@@ -385,7 +386,8 @@ export function Table({
const columnOptions = useMemo<ColumnOption[]>(
() =>
columns.map((col) => ({
id: col.name,
// `id` is the filter/sort field key (column id); `label` is what the user sees.
id: getColumnId(col),
label: col.name,
type: col.type,
icon: COLUMN_TYPE_ICONS[col.type],
@@ -648,7 +650,7 @@ export function Table({
onClose={onCloseSlideout}
existingColumn={
columnConfig?.mode === 'edit'
? (columns.find((c) => c.name === columnConfig.columnName) ?? null)
? (columns.find((c) => getColumnId(c) === columnConfig.columnName) ?? null)
: null
}
workspaceId={workspaceId}
@@ -12,6 +12,7 @@ import { createTimeoutAbortController } from '@/lib/core/execution-limits'
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { withCascadeLock } from '@/lib/table/cascade-lock'
import { getColumnId } from '@/lib/table/column-keys'
import { isExecCancelled } from '@/lib/table/deps'
import { appendTableEvent } from '@/lib/table/events'
import type {
@@ -559,26 +560,30 @@ async function runWorkflowAndWriteTerminal(
// populated by the run we're starting. Other group's outputs ARE
// included (they're plain primitives in `row.data` thanks to the
// flattened schema).
const ownOutputColumns = new Set(group.outputs.map((o) => o.columnName))
// `inputRow` is name-keyed: the workflow author references columns by name
// in the Start block and downstream blocks, while stored `row.data` is
// id-keyed. Translate, skipping this group's own output columns.
const ownOutputColumnIds = new Set(group.outputs.map((o) => o.columnName))
const inputRow: Record<string, unknown> = {}
for (const key of Object.keys(row.data)) {
if (ownOutputColumns.has(key)) continue
inputRow[key] = row.data[key]
for (const col of table.schema.columns) {
const id = getColumnId(col)
if (ownOutputColumnIds.has(id)) continue
inputRow[col.name] = row.data[id]
}
const headers = table.schema.columns
.filter((c) => !ownOutputColumns.has(c.name))
.filter((c) => !ownOutputColumnIds.has(getColumnId(c)))
.map((c) => c.name)
// When the group has explicit input mappings, feed the workflow's
// Start-block fields from the mapped columns (`inputName ← row[columnName]`).
// Start-block fields from the mapped columns (`inputName ← row[columnId]`).
// Otherwise fall back to spreading every non-output column by name, so a
// Start field still resolves when it matches a column name. `row`/`rawRow`
// always carry the full row for downstream reference.
// always carry the full (name-keyed) row for downstream reference.
const inputMappings = group.inputMappings ?? []
const mappedInputs: Record<string, unknown> = {}
for (const m of inputMappings) {
mappedInputs[m.inputName] = inputRow[m.columnName]
mappedInputs[m.inputName] = row.data[m.columnName]
}
const input = {
+10 -3
View File
@@ -214,7 +214,7 @@ describe('useUpdateColumn optimistic update', () => {
expect(getCache(tableKeys.detail(TABLE_ID))).toEqual(original)
})
it('renames the corresponding row-data key when updates.name is set', async () => {
it('renames metadata-only: patches the column name + stamps id, leaves row data untouched', async () => {
setCache(tableKeys.detail(TABLE_ID), {
id: TABLE_ID,
schema: { columns: [{ name: 'age', type: 'number' }] },
@@ -230,11 +230,18 @@ describe('useUpdateColumn optimistic update', () => {
const hook = useUpdateColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
await hook.onMutate?.({ columnName: 'age', updates: { name: 'years' } })
// Row data is id-keyed; a rename never moves it. The stored key (`age`)
// becomes the column's stamped id, so cells stay reachable via getColumnId.
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(
tableKeys.rowsRoot(TABLE_ID)
)
expect(rows?.rows[0]?.data).toEqual({ years: 30 })
expect(rows?.rows[1]?.data).toEqual({ years: 40 })
expect(rows?.rows[0]?.data).toEqual({ age: 30 })
expect(rows?.rows[1]?.data).toEqual({ age: 40 })
const detail = getCache<{ schema: { columns: Array<{ id?: string; name: string }> } }>(
tableKeys.detail(TABLE_ID)
)
expect(detail?.schema.columns[0]).toMatchObject({ id: 'age', name: 'years' })
})
})
+27 -28
View File
@@ -77,6 +77,7 @@ import type {
WorkflowGroupDependencies,
WorkflowGroupOutput,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import {
areGroupDepsSatisfied,
@@ -1050,39 +1051,31 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) {
await queryClient.cancelQueries({ queryKey: tableKeys.detail(tableId) })
const previousDetail = queryClient.getQueryData<TableDefinition>(tableKeys.detail(tableId))
if (previousDetail) {
// `columnName` is the column id (first-party) or name (legacy); match
// either. A rename is metadata-only and never moves id-keyed row data,
// so we only patch the schema column's name — never `row.data` keys.
// Stamp the current storage id so `getColumnId` stays stable as the
// display name changes (mirrors the server's metadata-only rename).
const lower = columnName.toLowerCase()
const nextColumns = previousDetail.schema.columns.map((c) =>
c.name.toLowerCase() === lower ? { ...c, ...updates } : c
)
const isRename = typeof (updates as { name?: string }).name === 'string'
const nextColumns = previousDetail.schema.columns.map((c) => {
if (getColumnId(c) !== columnName && c.name.toLowerCase() !== lower) return c
const next = { ...c, ...updates }
if (isRename && next.id === undefined) next.id = getColumnId(c)
return next
})
queryClient.setQueryData<TableDefinition>(tableKeys.detail(tableId), {
...previousDetail,
schema: { ...previousDetail.schema, columns: nextColumns },
})
}
const newName = (updates as { name?: string }).name
const rowSnapshots =
typeof newName === 'string' && newName.length > 0 && newName !== columnName
? await snapshotAndMutateRows(queryClient, tableId, (row) => {
const lower = columnName.toLowerCase()
const matchKey = Object.keys(row.data).find((k) => k.toLowerCase() === lower)
if (!matchKey) return null
const { [matchKey]: value, ...rest } = row.data
return { ...row, data: { ...rest, [newName]: value } }
})
: []
return { previousDetail, rowSnapshots }
return { previousDetail }
},
onError: (error, _vars, context) => {
if (context?.previousDetail) {
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
}
if (context?.rowSnapshots) {
for (const [key, data] of context.rowSnapshots) {
queryClient.setQueryData(key, data)
}
}
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1503,16 +1496,23 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
const lower = columnName.toLowerCase()
const previousDetail = queryClient.getQueryData<TableDefinition>(tableKeys.detail(tableId))
// The grid deletes by stable id; legacy callers may pass a name. Resolve
// the column's storage id once from either form, then strip schema,
// widths, and row data by that single id — all three are id-keyed, so a
// name arg with a distinct id must never be used as the strip key directly.
const target = previousDetail?.schema.columns.find(
(c) => getColumnId(c) === columnName || c.name.toLowerCase() === lower
)
const stripKey = target ? getColumnId(target) : columnName
if (previousDetail) {
const nextColumns = previousDetail.schema.columns.filter(
(c) => c.name.toLowerCase() !== lower
)
const nextColumns = previousDetail.schema.columns.filter((c) => getColumnId(c) !== stripKey)
const prevWidths = previousDetail.metadata?.columnWidths
const nextMetadata = prevWidths
? {
...previousDetail.metadata,
columnWidths: Object.fromEntries(
Object.entries(prevWidths).filter(([k]) => k.toLowerCase() !== lower)
Object.entries(prevWidths).filter(([k]) => k !== stripKey)
),
}
: previousDetail.metadata
@@ -1524,9 +1524,8 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
}
const rowSnapshots = await snapshotAndMutateRows(queryClient, tableId, (row) => {
const matchKey = Object.keys(row.data).find((k) => k.toLowerCase() === lower)
if (!matchKey) return null
const { [matchKey]: _removed, ...rest } = row.data
if (!(stripKey in row.data)) return null
const { [stripKey]: _removed, ...rest } = row.data
return { ...row, data: rest }
})
+31 -22
View File
@@ -222,19 +222,22 @@ export function useTableUndo({
}
case 'create-column': {
// Identity (delete lookup + id-keyed metadata) uses the stable id;
// re-create uses the display name.
const colKey = action.columnId ?? action.columnName
if (direction === 'undo') {
deleteColumnMutation.mutate(action.columnName, {
deleteColumnMutation.mutate(colKey, {
onSuccess: () => {
const metadata: Record<string, unknown> = {}
const currentWidths = getColumnWidthsRef.current?.() ?? {}
if (action.columnName in currentWidths) {
const { [action.columnName]: _, ...rest } = currentWidths
if (colKey in currentWidths) {
const { [colKey]: _, ...rest } = currentWidths
onColumnWidthsChangeRef.current?.(rest)
metadata.columnWidths = rest
}
const currentPinned = getPinnedColumnsRef.current?.() ?? []
if (currentPinned.includes(action.columnName)) {
const newPinned = currentPinned.filter((n) => n !== action.columnName)
if (currentPinned.includes(colKey)) {
const newPinned = currentPinned.filter((n) => n !== colKey)
onPinnedColumnsChangeRef.current?.(newPinned)
metadata.pinnedColumns = newPinned
}
@@ -245,6 +248,7 @@ export function useTableUndo({
})
} else {
addColumnMutation.mutate({
...(action.columnId ? { id: action.columnId } : {}),
name: action.columnName,
type: 'string',
position: action.position,
@@ -254,9 +258,15 @@ export function useTableUndo({
}
case 'delete-column': {
// Identity (cell-data keys, id-keyed metadata, delete lookup) uses the
// stable id; re-create uses the display name.
const colKey = action.columnId ?? action.columnName
if (direction === 'undo') {
addColumnMutation.mutate(
{
// Reuse the original id so the saved (id-keyed) cell data below
// lands on the restored column.
...(action.columnId ? { id: action.columnId } : {}),
name: action.columnName,
type: action.columnType,
required: action.columnRequired,
@@ -268,7 +278,7 @@ export function useTableUndo({
if (action.cellData.length > 0) {
const updates = action.cellData.map((c) => ({
rowId: c.rowId,
data: { [action.columnName]: c.value },
data: { [colKey]: c.value },
}))
void (async () => {
try {
@@ -297,26 +307,22 @@ export function useTableUndo({
if (action.previousWidth !== null) {
const merged = {
...(getColumnWidthsRef.current?.() ?? {}),
[action.columnName]: action.previousWidth,
[colKey]: action.previousWidth,
}
metadata.columnWidths = merged
onColumnWidthsChangeRef.current?.(merged)
}
if (action.previousPinnedColumns !== null) {
const wasColumnPinned = action.previousPinnedColumns.includes(
action.columnName
)
const wasColumnPinned = action.previousPinnedColumns.includes(colKey)
if (wasColumnPinned) {
const currentPinned = getPinnedColumnsRef.current?.() ?? []
if (!currentPinned.includes(action.columnName)) {
const insertIndex = action.previousPinnedColumns.indexOf(
action.columnName
)
if (!currentPinned.includes(colKey)) {
const insertIndex = action.previousPinnedColumns.indexOf(colKey)
const restoredPinned = [...currentPinned]
restoredPinned.splice(
Math.min(insertIndex, restoredPinned.length),
0,
action.columnName
colKey
)
onPinnedColumnsChangeRef.current?.(restoredPinned)
metadata.pinnedColumns = restoredPinned
@@ -330,24 +336,24 @@ export function useTableUndo({
}
)
} else {
deleteColumnMutation.mutate(action.columnName, {
deleteColumnMutation.mutate(colKey, {
onSuccess: () => {
const metadata: Record<string, unknown> = {}
if (action.previousOrder) {
const newOrder = action.previousOrder.filter((n) => n !== action.columnName)
const newOrder = action.previousOrder.filter((n) => n !== colKey)
onColumnOrderChangeRef.current?.(newOrder)
metadata.columnOrder = newOrder
}
if (action.previousWidth !== null) {
const currentWidths = getColumnWidthsRef.current?.() ?? {}
const { [action.columnName]: _, ...rest } = currentWidths
const { [colKey]: _, ...rest } = currentWidths
metadata.columnWidths = rest
onColumnWidthsChangeRef.current?.(rest)
}
if (action.previousPinnedColumns !== null) {
const currentPinned = getPinnedColumnsRef.current?.() ?? []
if (currentPinned.includes(action.columnName)) {
const newPinned = currentPinned.filter((n) => n !== action.columnName)
if (currentPinned.includes(colKey)) {
const newPinned = currentPinned.filter((n) => n !== colKey)
onPinnedColumnsChangeRef.current?.(newPinned)
metadata.pinnedColumns = newPinned
}
@@ -364,11 +370,14 @@ export function useTableUndo({
case 'rename-column': {
const fromName = direction === 'undo' ? action.newName : action.oldName
const toName = direction === 'undo' ? action.oldName : action.newName
// Look up by the stable id (falls back to the current name) so undo
// never renames the column to its internal id.
const colKey = action.columnId ?? fromName
updateColumnMutation.mutate({
columnName: fromName,
columnName: colKey,
updates: { name: toName },
})
onColumnRenameRef.current?.(fromName, toName)
onColumnRenameRef.current?.(colKey, toName)
break
}
+8
View File
@@ -78,10 +78,14 @@ export const getTableQuerySchema = z.object({
})
export const tableColumnSchema = z.object({
/** Stable column id (server-assigned). Absent on legacy/ pre-backfill columns. */
id: z.string().optional(),
name: columnNameSchema,
type: columnTypeSchema,
required: z.boolean().optional().default(false),
unique: z.boolean().optional().default(false),
/** Set when the column is a workflow group's output. */
workflowGroupId: z.string().optional(),
})
export const createTableBodySchema = z.object({
@@ -108,6 +112,9 @@ export const renameTableBodySchema = z.object({
export const createTableColumnBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
column: z.object({
// Optional stable id — first-party undo of a delete re-creates the column
// with its original id so saved (id-keyed) cell data restores correctly.
id: z.string().optional(),
name: columnNameSchema,
type: columnTypeSchema,
required: z.boolean().optional(),
@@ -516,6 +523,7 @@ export const findTableRowsQuerySchema = z.object({
export const tableFindMatchSchema = z.object({
ordinal: z.number().int(),
rowId: z.string(),
/** Stable column id of the matching cell (JSONB storage key), not the display name. */
column: z.string(),
})
@@ -238,7 +238,8 @@ async function executeTable(
)
try {
const coerced = coerceRowsForTable(rows, schema, headerToColumn)
// Coerce against the created table's schema so rows key by assigned ids.
const coerced = coerceRowsForTable(rows, table.schema, headerToColumn)
let inserted = 0
for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) {
const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE)
@@ -19,6 +19,14 @@ import {
parseFileRows,
validateMapping,
} from '@/lib/table'
import {
buildIdByName,
buildNameById,
filterNamesToIds,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
} from '@/lib/table/column-keys'
import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming'
import {
addTableColumn,
@@ -319,10 +327,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
// The LLM authors row data by column name; storage keys by id.
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
const row = await insertRow(
{
tableId: args.tableId,
data: args.data,
data: rowDataNameToId(args.data, idByName),
workspaceId,
userId: context.userId,
position: args.position as number | undefined,
@@ -334,7 +345,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return {
success: true,
message: `Inserted row ${row.id}`,
data: { row },
data: { row: { ...row, data: rowDataIdToName(row.data, nameById) } },
}
}
@@ -370,10 +381,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
const rows = await batchInsertRows(
{
tableId: args.tableId,
rows: args.rows,
rows: args.rows.map((r: RowData) => rowDataNameToId(r, idByName)),
workspaceId,
userId: context.userId,
positions,
@@ -385,7 +398,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return {
success: true,
message: `Inserted ${rows.length} rows`,
data: { rows, insertedCount: rows.length },
data: {
rows: rows.map((r) => ({ ...r, data: rowDataIdToName(r.data, nameById) })),
insertedCount: rows.length,
},
}
}
@@ -400,15 +416,22 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return { success: false, message: 'Workspace ID is required' }
}
const rowTable = await getTableById(args.tableId)
if (!rowTable || rowTable.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const row = await getRowById(args.tableId, args.rowId, workspaceId)
if (!row) {
return { success: false, message: `Row not found: ${args.rowId}` }
}
const nameById = buildNameById(rowTable.schema)
return {
success: true,
message: `Row ${row.id}`,
data: { row },
data: {
row: { ...row, data: rowDataIdToName(row.data, nameById) },
},
}
}
@@ -426,11 +449,13 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
}
const requestId = generateId().slice(0, 8)
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
const result = await queryRows(
table,
{
filter: args.filter,
sort: args.sort,
filter: args.filter ? filterNamesToIds(args.filter, idByName) : undefined,
sort: args.sort ? sortNamesToIds(args.sort, idByName) : undefined,
limit: args.limit,
offset: args.offset,
},
@@ -440,7 +465,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return {
success: true,
message: `Returned ${result.rows.length} of ${result.totalCount} rows`,
data: result,
data: {
...result,
rows: result.rows.map((r) => ({ ...r, data: rowDataIdToName(r.data, nameById) })),
},
}
}
@@ -465,8 +493,15 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
const updatedRow = await updateRow(
{ tableId: args.tableId, rowId: args.rowId, data: args.data, workspaceId },
{
tableId: args.tableId,
rowId: args.rowId,
data: rowDataNameToId(args.data, idByName),
workspaceId,
},
table,
requestId
)
@@ -484,7 +519,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
return {
success: true,
message: `Updated row ${updatedRow.id}`,
data: { row: updatedRow },
data: { row: { ...updatedRow, data: rowDataIdToName(updatedRow.data, nameById) } },
}
}
@@ -530,11 +565,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const result = await updateRowsByFilter(
table,
{
filter: args.filter,
data: args.data,
filter: filterNamesToIds(args.filter, idByName),
data: rowDataNameToId(args.data, idByName),
limit: args.limit,
},
requestId
@@ -565,10 +601,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const result = await deleteRowsByFilter(
table,
{
filter: args.filter,
filter: filterNamesToIds(args.filter, idByName),
limit: args.limit,
},
requestId
@@ -627,10 +664,14 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const result = await batchUpdateRows(
{
tableId: args.tableId,
updates: updates as Array<{ rowId: string; data: RowData }>,
updates: (updates as Array<{ rowId: string; data: RowData }>).map((u) => ({
rowId: u.rowId,
data: rowDataNameToId(u.data, idByName),
})),
workspaceId,
},
table,
@@ -724,7 +765,9 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
requestId
)
const coerced = coerceRowsForTable(rowsToImport, { columns }, headerToColumn)
// Coerce against the created table's schema so rows key by the ids
// `createTable` assigned (not the inferred, id-less columns).
const coerced = coerceRowsForTable(rowsToImport, table.schema, headerToColumn)
let inserted: number
try {
inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context)
@@ -0,0 +1,162 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
const { mockGenerateId } = vi.hoisted(() => ({
mockGenerateId: vi.fn(),
}))
vi.mock('@sim/utils/id', () => ({
generateId: mockGenerateId,
}))
import {
buildIdByName,
buildNameById,
filterNamesToIds,
generateColumnId,
getColumnId,
remapGroupColumnRefs,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
withGeneratedColumnIds,
} from '@/lib/table/column-keys'
import type { TableSchema, WorkflowGroup } from '@/lib/table/types'
describe('getColumnId', () => {
it('returns the explicit id when present', () => {
expect(getColumnId({ id: 'col_abc', name: 'email' })).toBe('col_abc')
})
it('falls back to name for legacy id-less columns', () => {
expect(getColumnId({ name: 'email' })).toBe('email')
})
})
describe('generateColumnId', () => {
it('mints a col_-prefixed id with the uuid dashes stripped', () => {
mockGenerateId.mockReturnValue('11111111-2222-4333-8444-555566667777')
expect(generateColumnId()).toBe('col_11111111222243338444555566667777')
})
it('produces an id that satisfies NAME_PATTERN (valid JSONB key / filter field)', () => {
mockGenerateId.mockReturnValue('0a1b2c3d-4e5f-4607-8809-0a1b2c3d4e5f')
// Must start with a letter/underscore and contain only [a-z0-9_].
expect(generateColumnId()).toMatch(/^[a-z_][a-z0-9_]*$/i)
})
})
describe('name ↔ id maps', () => {
const schema: TableSchema = {
columns: [
{ id: 'col_1', name: 'email', type: 'string' },
{ name: 'age', type: 'number' }, // legacy: id == name
],
}
it('buildIdByName maps display name → storage id', () => {
expect(Object.fromEntries(buildIdByName(schema))).toEqual({ email: 'col_1', age: 'age' })
})
it('buildNameById maps storage id → display name', () => {
expect(Object.fromEntries(buildNameById(schema))).toEqual({ col_1: 'email', age: 'age' })
})
})
describe('row data translation', () => {
const schema: TableSchema = {
columns: [
{ id: 'col_1', name: 'email', type: 'string' },
{ name: 'age', type: 'number' },
],
}
const idByName = buildIdByName(schema)
const nameById = buildNameById(schema)
it('round-trips name → id → name', () => {
const wire = { email: 'a@b.c', age: 30 }
const stored = rowDataNameToId(wire, idByName)
expect(stored).toEqual({ col_1: 'a@b.c', age: 30 })
expect(rowDataIdToName(stored, nameById)).toEqual(wire)
})
it('drops keys with no matching column (orphans / unknowns)', () => {
expect(rowDataNameToId({ email: 'x', ghost: 1 }, idByName)).toEqual({ col_1: 'x' })
expect(rowDataIdToName({ col_1: 'x', col_gone: 9 }, nameById)).toEqual({ email: 'x' })
})
})
describe('filter / sort translation', () => {
const idByName = new Map([
['email', 'col_1'],
['age', 'col_2'],
])
it('translates field names, recurses $or/$and, passes through unknown fields', () => {
const filter = {
email: 'a@b.c',
$or: [{ age: { $gt: 18 } }, { createdAt: { $gt: '2024' } }],
}
expect(filterNamesToIds(filter, idByName)).toEqual({
col_1: 'a@b.c',
$or: [{ col_2: { $gt: 18 } }, { createdAt: { $gt: '2024' } }],
})
})
it('translates sort field names, passes through unknown', () => {
expect(sortNamesToIds({ email: 'asc', createdAt: 'desc' }, idByName)).toEqual({
col_1: 'asc',
createdAt: 'desc',
})
})
})
describe('withGeneratedColumnIds', () => {
it('stamps ids on id-less columns and remaps group refs name → id', () => {
mockGenerateId.mockReturnValueOnce('a').mockReturnValueOnce('b')
const schema: TableSchema = {
columns: [
{ name: 'email', type: 'string', workflowGroupId: 'g1' },
{ name: 'score', type: 'number', workflowGroupId: 'g1' },
],
workflowGroups: [
{
id: 'g1',
workflowId: 'wf',
outputs: [{ blockId: 'b', path: 'p', columnName: 'score' }],
dependencies: { columns: ['email'] },
inputMappings: [{ inputName: 'in', columnName: 'email' }],
},
],
}
const out = withGeneratedColumnIds(schema)
expect(out.columns[0].id).toBe('col_a')
expect(out.columns[1].id).toBe('col_b')
const g = out.workflowGroups![0]
expect(g.outputs[0].columnName).toBe('col_b') // score
expect(g.dependencies!.columns).toEqual(['col_a']) // email
expect(g.inputMappings![0].columnName).toBe('col_a')
})
it('is idempotent for columns that already have an id', () => {
const schema: TableSchema = {
columns: [{ id: 'col_keep', name: 'email', type: 'string' }],
}
expect(withGeneratedColumnIds(schema).columns[0].id).toBe('col_keep')
})
})
describe('remapGroupColumnRefs', () => {
it('rewrites refs that are names, leaves refs that are already ids', () => {
const idByName = new Map([['email', 'col_1']])
const group: WorkflowGroup = {
id: 'g',
workflowId: 'wf',
outputs: [{ blockId: 'b', path: 'p', columnName: 'email' }],
dependencies: { columns: ['col_existing'] },
}
const out = remapGroupColumnRefs(group, idByName)
expect(out.outputs[0].columnName).toBe('col_1')
expect(out.dependencies!.columns).toEqual(['col_existing'])
})
})
@@ -418,13 +418,14 @@ describe('mutation paths — SET LOCAL timeouts', () => {
expect(findExecutedRawSql("SET LOCAL statement_timeout = '120000ms'")).toBeDefined()
})
it('renameColumn scales statement_timeout with table.rowCount', async () => {
it('renameColumn is metadata-only — no per-row JSONB rewrite regardless of row count', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ ...TABLE, rowCount: 500_000 }])
await renameColumn({ tableId: 'tbl-1', oldName: 'name', newName: 'full_name' }, 'req-1')
// 500_000 × 2ms = 1_000_000 → capped at 600_000
expect(findExecutedRawSql("SET LOCAL statement_timeout = '600000ms'")).toBeDefined()
// Row data is keyed by the column's stable id (unchanged by a rename), so no
// `user_table_rows` key rewrite is executed — and thus no scaled timeout.
expect(findExecutedSqlContaining('jsonb_build_object')).toBe(false)
})
it('deleteColumn uses the 60s floor on small tables', async () => {
+180
View File
@@ -0,0 +1,180 @@
/**
* Column id name translation helpers.
*
* Stored row data (`user_table_rows.data`), table metadata, workflow-group
* refs, and filter/sort all key on a column's stable **id**. `name` is a
* display label that changes on rename. The two name-translating boundaries
* (public v1 API, mothership tool) and CSV convert between the two with the
* map builders here.
*/
import { generateId } from '@sim/utils/id'
import type { ColumnDefinition, Filter, RowData, Sort, TableSchema, WorkflowGroup } from './types'
/**
* Resolves a column's stable storage key. Falls back to `name` for legacy
* columns that predate the id backfill those rows were written keyed by name,
* which is exactly the key the column still uses, so the fallback is correct.
*/
export function getColumnId(col: Pick<ColumnDefinition, 'id' | 'name'>): string {
return col.id ?? col.name
}
/**
* Mints a fresh column id. Generated ids are opaque (`col_<uuid>`) and
* deliberately distinct from display names so renames never disturb them. The
* `col_` prefix is required: the id is validated against `NAME_PATTERN` (it's a
* JSONB key and a filter/sort field) which must start with a letter/underscore,
* and a bare UUID can start with a digit. Dashes are stripped for the same
* reason. A v4 UUID's 122 random bits make a collision within a table's columns
* effectively impossible, so no uniqueness check is needed.
*/
export function generateColumnId(): string {
return `col_${generateId().replace(/-/g, '')}`
}
/**
* Matches a column against a reference that may be a stable id (first-party
* callers) or a display name (legacy / mothership / public API). Id match is
* exact; name match is case-insensitive (names are unique case-insensitively per
* schema validation). The single predicate behind every column-op resolver use
* with `.find` / `.findIndex` so id-or-name resolution can't drift between sites.
*/
export function columnMatchesRef(col: ColumnDefinition, ref: string): boolean {
return getColumnId(col) === ref || col.name.toLowerCase() === ref.toLowerCase()
}
/**
* Returns a schema copy with a generated id stamped onto every column that
* lacks one, remapping any workflow-group refs that still hold a column **name**
* to the assigned id. Used at creation time (`createTable`) so a freshly created
* table is fully id-keyed from its first row write. Idempotent for columns that
* already carry an id.
*/
export function withGeneratedColumnIds(schema: TableSchema): TableSchema {
const idByName = new Map<string, string>()
const columns = schema.columns.map((col) => {
if (col.id) {
idByName.set(col.name, col.id)
return col
}
const id = generateColumnId()
idByName.set(col.name, id)
return { ...col, id }
})
const remap = (ref: string) => idByName.get(ref) ?? ref
const workflowGroups = schema.workflowGroups?.map((group) => ({
...group,
outputs: group.outputs.map((o) => ({ ...o, columnName: remap(o.columnName) })),
...(group.dependencies?.columns
? { dependencies: { columns: group.dependencies.columns.map(remap) } }
: {}),
...(group.inputMappings
? {
inputMappings: group.inputMappings.map((m) => ({
...m,
columnName: remap(m.columnName),
})),
}
: {}),
}))
return { ...schema, columns, ...(workflowGroups ? { workflowGroups } : {}) }
}
/**
* Rewrites a workflow group's column references (output `columnName`,
* `dependencies.columns`, `inputMapping.columnName`) from display name to stable
* id using `idByName`. A ref that is already an id (not a known column name) is
* left as-is, so this is safe whether the caller authored refs by name
* (mothership) or by id (first-party UI).
*/
export function remapGroupColumnRefs(
group: WorkflowGroup,
idByName: ReadonlyMap<string, string>
): WorkflowGroup {
const remap = (ref: string) => idByName.get(ref) ?? ref
return {
...group,
outputs: group.outputs.map((o) => ({ ...o, columnName: remap(o.columnName) })),
...(group.dependencies?.columns
? { dependencies: { columns: group.dependencies.columns.map(remap) } }
: {}),
...(group.inputMappings
? {
inputMappings: group.inputMappings.map((m) => ({
...m,
columnName: remap(m.columnName),
})),
}
: {}),
}
}
/** `name → id` for translating inbound wire data (v1 / mothership / CSV import). */
export function buildIdByName(schema: TableSchema): Map<string, string> {
const map = new Map<string, string>()
for (const col of schema.columns) map.set(col.name, getColumnId(col))
return map
}
/** `id → name` for translating outbound wire data (v1 / mothership / CSV export). */
export function buildNameById(schema: TableSchema): Map<string, string> {
const map = new Map<string, string>()
for (const col of schema.columns) map.set(getColumnId(col), col.name)
return map
}
/**
* Remaps a wire row keyed by column **name** to the stored **id** keying. Used
* at the name-translating boundaries on the way in. Keys not matching a known
* column are dropped (validation has already run against the schema).
*/
export function rowDataNameToId(data: RowData, idByName: Map<string, string>): RowData {
const out: RowData = {}
for (const [name, value] of Object.entries(data)) {
const id = idByName.get(name)
if (id !== undefined) out[id] = value
}
return out
}
/**
* Translates a filter's field names column ids (recursing into `$or`/`$and`).
* Fields with no matching column (e.g. `createdAt`) pass through unchanged. Used
* at the name-translating boundaries before handing a filter to the query layer.
*/
export function filterNamesToIds(filter: Filter, idByName: ReadonlyMap<string, string>): Filter {
const out: Filter = {}
for (const [key, value] of Object.entries(filter)) {
if ((key === '$or' || key === '$and') && Array.isArray(value)) {
out[key] = (value as Filter[]).map((f) => filterNamesToIds(f, idByName))
} else {
out[idByName.get(key) ?? key] = value
}
}
return out
}
/** Translates a sort's field names → column ids. Unknown fields pass through. */
export function sortNamesToIds(sort: Sort, idByName: ReadonlyMap<string, string>): Sort {
const out: Sort = {}
for (const [field, dir] of Object.entries(sort)) out[idByName.get(field) ?? field] = dir
return out
}
/**
* Remaps a stored row keyed by column **id** back to **name** keying for the
* wire. Used at the name-translating boundaries on the way out. Ids with no
* current column (e.g. a column deleted by a not-yet-finished background strip)
* are dropped, so orphaned keys never surface.
*/
export function rowDataIdToName(data: RowData, nameById: Map<string, string>): RowData {
const out: RowData = {}
for (const [id, value] of Object.entries(data)) {
const name = nameById.get(id)
if (name !== undefined) out[name] = value
}
return out
}
+4 -1
View File
@@ -17,6 +17,7 @@ import {
type TableSchema,
validateMapping,
} from '@/lib/table'
import { withGeneratedColumnIds } from '@/lib/table/column-keys'
import { appendTableEvent } from '@/lib/table/events'
import {
addImportColumns,
@@ -129,7 +130,9 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
if (mode === 'create') {
const inferred = inferSchemaFromCsv(headers, sample)
schema = { columns: inferred.columns.map(normalizeColumn) }
// Stamp ids so the imported table is id-native (rows coerce + persist by
// the same ids).
schema = withGeneratedColumnIds({ columns: inferred.columns.map(normalizeColumn) })
headerToColumn = inferred.headerToColumn
await setTableSchemaForImport(tableId, schema)
return
+11 -7
View File
@@ -12,6 +12,7 @@
*/
import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse'
import { getColumnId } from '@/lib/table/column-keys'
import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types'
/**
@@ -392,25 +393,28 @@ export function buildAutoMapping(csvHeaders: string[], tableSchema: TableSchema)
}
/**
* Coerces parsed CSV rows into `RowData` objects keyed by target column name,
* applying the column types declared in `tableSchema`. Headers not present in
* `headerToColumn` are dropped. Missing table columns remain unset (schema
* validation decides whether that's acceptable).
* Coerces parsed CSV rows into `RowData` objects keyed by the target column's
* **stable id** (the row-data storage key), applying the column types declared in
* `tableSchema`. Headers not present in `headerToColumn` are dropped. Missing
* table columns remain unset (schema validation decides whether that's
* acceptable). Pass the schema returned by `createTable` so ids are resolved.
*/
export function coerceRowsForTable(
rows: Record<string, unknown>[],
tableSchema: TableSchema,
headerToColumn: Map<string, string>
): RowData[] {
const typeByName = new Map(tableSchema.columns.map((c) => [c.name, c.type as CsvColumnType]))
const colByName = new Map(tableSchema.columns.map((c) => [c.name, c]))
return rows.map((row) => {
const coerced: RowData = {}
for (const [header, value] of Object.entries(row)) {
const colName = headerToColumn.get(header)
if (!colName) continue
const colType = typeByName.get(colName) ?? 'string'
coerced[colName] = coerceValue(value, colType) as RowData[string]
const col = colByName.get(colName)
if (!col) continue
const colType = (col.type as CsvColumnType) ?? 'string'
coerced[getColumnId(col)] = coerceValue(value, colType) as RowData[string]
}
return coerced
})
+1
View File
@@ -6,6 +6,7 @@
*/
export * from './billing'
export * from './column-keys'
export * from './constants'
export * from './import'
export * from './llm'
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -7,6 +7,7 @@
import type { SQL } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { getColumnId } from './column-keys'
import { NAME_PATTERN } from './constants'
import type { ColumnDefinition, ConditionOperators, Filter, JsonValue, Sort } from './types'
@@ -41,8 +42,13 @@ function jsonbCastForType(type: ColumnType | undefined): 'numeric' | 'timestampt
}
}
/**
* Maps a column's **stable id** (the JSONB storage key, via `getColumnId`) to
* its type. Filter/sort objects arrive keyed by column id, so the lookups in the
* clause builders use ids not display names.
*/
function buildColumnTypeMap(columns: ColumnDefinition[]): ColumnTypeMap {
return new Map(columns.map((col) => [col.name, col.type]))
return new Map(columns.map((col) => [getColumnId(col), col.type]))
}
/**
+16 -5
View File
@@ -8,6 +8,7 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys'
import type { RowData, TableRow, TableSchema } from '@/lib/table/types'
const logger = createLogger('TableTrigger')
@@ -62,6 +63,9 @@ export async function fireTableTrigger(
if (webhooks.length === 0) return
const headers = schema.columns.map((c) => c.name)
// The webhook payload is name-keyed (the workflow author references columns
// by name); stored row data is id-keyed, so translate on the way out.
const nameById = buildNameById(schema)
// Filter to webhooks watching this table with a matching event type
const matching = webhooks.filter((entry) => {
@@ -87,8 +91,15 @@ export async function fireTableTrigger(
const includeHeaders = config?.includeHeaders !== false
for (const row of rows) {
const previousRow = oldRows?.get(row.id) ?? null
const changedColumns = previousRow ? detectChangedColumns(previousRow, row.data) : []
const previousIdData = oldRows?.get(row.id) ?? null
// Translate id-keyed stored data → name-keyed for the external payload.
const rawRow = rowDataIdToName(row.data, nameById)
const previousRow = previousIdData ? rowDataIdToName(previousIdData, nameById) : null
const changedColumns = previousIdData
? detectChangedColumns(previousIdData, row.data)
.map((id) => nameById.get(id))
.filter((name): name is string => name !== undefined)
: []
// For updates with watch columns, skip rows where no watched column changed
if (eventType === 'update' && watchColumns.length > 0) {
@@ -100,14 +111,14 @@ export async function fireTableTrigger(
let mappedRow: Record<string, unknown> | null = null
if (includeHeaders && headers.length > 0) {
mappedRow = {}
for (const header of headers) {
mappedRow[header] = row.data[header] ?? null
for (const col of schema.columns) {
mappedRow[col.name] = row.data[getColumnId(col)] ?? null
}
}
const payload: TableTriggerPayload = {
row: mappedRow,
rawRow: row.data,
rawRow,
previousRow,
changedColumns,
rowId: row.id,
+35 -9
View File
@@ -7,7 +7,12 @@ import type { COLUMN_TYPES } from './constants'
export type ColumnValue = string | number | boolean | null | Date
export type JsonValue = ColumnValue | JsonValue[] | { [key: string]: JsonValue }
/** Row data mapping column names to values. */
/**
* Row data mapping **column id** value at rest (in `user_table_rows.data`).
* The two name-translating boundaries (public v1 API, mothership tool) and CSV
* key by column name on the wire; everything else uses ids. Resolve a column's
* storage key with `getColumnId` from `./column-keys`.
*/
export type RowData = Record<string, JsonValue>
export type SortDirection = 'asc' | 'desc'
@@ -22,13 +27,22 @@ export interface ColumnOption {
}
export interface ColumnDefinition {
/**
* Stable storage key for this column. Row data, metadata, workflow-group
* refs, and filter/sort all key on this id; `name` is a pure display label
* that can change freely (rename is metadata-only). Absent only on legacy
* columns before the backfill `getColumnId` falls back to `name`, which is
* the key those rows were already written under. New columns get a generated
* `col_…` from `generateColumnId`.
*/
id?: string
name: string
type: (typeof COLUMN_TYPES)[number]
required?: boolean
unique?: boolean
/**
* When set, this column is one of a workflow group's outputs. The value in
* `row.data[name]` is populated by the group's per-cell run.
* `row.data[getColumnId(col)]` is populated by the group's per-cell run.
*/
workflowGroupId?: string
}
@@ -41,16 +55,22 @@ export interface WorkflowGroupOutput {
path: string
/** Enrichment output id this column receives (enrichment groups only). */
outputId?: string
/** Plain column in `schema.columns` that receives the produced value. */
/**
* Stable **column id** (`getColumnId`) of the plain column in
* `schema.columns` that receives the produced value. Despite the field name,
* this holds the column id, not its display name so a column rename never
* touches this ref. Legacy values equal the column name (== id pre-backfill).
*/
columnName: string
}
export interface WorkflowGroupDependencies {
/**
* Columns that must be non-empty before this group runs. Workflow output
* columns count too once an upstream group fills its output column, any
* downstream group depending on that column becomes eligible. The user
* model is uniform: deps are columns, not group-completion edges.
* Stable **column ids** (`getColumnId`) that must be non-empty before this
* group runs. Workflow output columns count too once an upstream group
* fills its output column, any downstream group depending on that column
* becomes eligible. The user model is uniform: deps are columns, not
* group-completion edges. Legacy values equal column names (== id pre-backfill).
*/
columns?: string[]
}
@@ -74,7 +94,11 @@ export type WorkflowGroupDeploymentMode = 'live' | 'deployed'
export interface WorkflowGroupInputMapping {
/** `inputFormat` field name on the workflow's Start block. */
inputName: string
/** Table column whose per-row value feeds that input. */
/**
* Stable **column id** (`getColumnId`) whose per-row value feeds that input.
* Despite the field name, this holds the column id, not its display name.
* Legacy values equal the column name (== id pre-backfill).
*/
columnName: string
}
@@ -159,9 +183,11 @@ export interface TableSchema {
* is enforced at the trigger.dev queue layer, not via metadata.
*/
export interface TableMetadata {
/** Pixel widths keyed by **column id** (`getColumnId`). */
columnWidths?: Record<string, number>
/** Visible left-to-right order as **column ids** (`getColumnId`). */
columnOrder?: string[]
/** Logical column names that are pinned to the left while scrolling horizontally. */
/** **Column ids** pinned to the left while scrolling horizontally. */
pinnedColumns?: string[]
}
+32 -26
View File
@@ -6,6 +6,7 @@ import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { and, eq, or, sql } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getColumnId } from './column-keys'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants'
import type { ColumnDefinition, JsonValue, RowData, TableSchema, ValidationResult } from './types'
@@ -207,7 +208,7 @@ export function validateRowAgainstSchema(data: RowData, schema: TableSchema): Va
const errors: string[] = []
for (const column of schema.columns) {
const value = data[column.name]
const value = data[getColumnId(column)]
if (column.required && (value === undefined || value === null)) {
errors.push(`Missing required field: ${column.name}`)
@@ -317,14 +318,15 @@ function coerceValueToColumnType(
*/
export function coerceRowValues(data: RowData, schema: TableSchema): void {
for (const column of schema.columns) {
const value = data[column.name]
const key = getColumnId(column)
const value = data[key]
if (value === null || value === undefined) continue
const coerced = coerceValueToColumnType(value, column.type)
if (coerced.ok) {
data[column.name] = coerced.value
data[key] = coerced.value
} else if (!column.required) {
data[column.name] = null
data[key] = null
}
}
}
@@ -373,13 +375,14 @@ export function validateUniqueConstraints(
const uniqueColumns = getUniqueColumns(schema)
for (const column of uniqueColumns) {
const value = data[column.name]
const key = getColumnId(column)
const value = data[key]
if (value === null || value === undefined) continue
const duplicate = existingRows.find((row) => {
if (excludeRowId && row.id === excludeRowId) return false
const existingValue = row.data[column.name]
const existingValue = row.data[key]
if (typeof value === 'string' && typeof existingValue === 'string') {
return value.toLowerCase() === existingValue.toLowerCase()
}
@@ -420,25 +423,26 @@ export async function checkUniqueConstraintsDb(
const conditions = []
for (const column of uniqueColumns) {
if (!NAME_PATTERN.test(column.name)) {
throw new Error(`Invalid column name: ${column.name}`)
const key = getColumnId(column)
if (!NAME_PATTERN.test(key)) {
throw new Error(`Invalid column id: ${key}`)
}
const value = data[column.name]
const value = data[key]
if (value === null || value === undefined) continue
if (typeof value === 'string') {
conditions.push({
column,
value,
sql: sql`lower(${userTableRows.data}->>${sql.raw(`'${column.name}'`)}) = ${value.toLowerCase()}`,
sql: sql`lower(${userTableRows.data}->>${sql.raw(`'${key}'`)}) = ${value.toLowerCase()}`,
})
} else {
// For other types, use direct JSONB comparison
conditions.push({
column,
value,
sql: sql`(${userTableRows.data}->${sql.raw(`'${column.name}'`)})::jsonb = ${JSON.stringify(value)}::jsonb`,
sql: sql`(${userTableRows.data}->${sql.raw(`'${key}'`)})::jsonb = ${JSON.stringify(value)}::jsonb`,
})
}
}
@@ -499,18 +503,19 @@ export async function checkBatchUniqueConstraintsDb(
return { valid: true, errors: [] }
}
// Build a set of all unique values for each column to check against DB
// Build a set of all unique values for each column to check against DB.
// Keyed by the stable column id (the row-data storage key).
const valuesByColumn = new Map<string, { values: Set<string>; column: ColumnDefinition }>()
for (const column of uniqueColumns) {
valuesByColumn.set(column.name, { values: new Set(), column })
valuesByColumn.set(getColumnId(column), { values: new Set(), column })
}
// Collect all unique values from the batch and check for duplicates within the batch
const batchValueMap = new Map<string, Map<string, number>>() // columnName -> (normalizedValue -> firstRowIndex)
const batchValueMap = new Map<string, Map<string, number>>() // columnId -> (normalizedValue -> firstRowIndex)
for (const column of uniqueColumns) {
batchValueMap.set(column.name, new Map())
batchValueMap.set(getColumnId(column), new Map())
}
for (let i = 0; i < rows.length; i++) {
@@ -518,14 +523,15 @@ export async function checkBatchUniqueConstraintsDb(
const currentRowErrors: string[] = []
for (const column of uniqueColumns) {
const value = rowData[column.name]
const key = getColumnId(column)
const value = rowData[key]
if (value === null || value === undefined) continue
const normalizedValue =
typeof value === 'string' ? value.toLowerCase() : JSON.stringify(value)
// Check for duplicate within batch
const columnValueMap = batchValueMap.get(column.name)!
const columnValueMap = batchValueMap.get(key)!
if (columnValueMap.has(normalizedValue)) {
const firstRowIndex = columnValueMap.get(normalizedValue)!
currentRowErrors.push(
@@ -533,7 +539,7 @@ export async function checkBatchUniqueConstraintsDb(
)
} else {
columnValueMap.set(normalizedValue, i)
valuesByColumn.get(column.name)!.values.add(normalizedValue)
valuesByColumn.get(key)!.values.add(normalizedValue)
}
}
@@ -543,11 +549,11 @@ export async function checkBatchUniqueConstraintsDb(
}
// Now check against database for all unique values at once
for (const [columnName, { values, column }] of valuesByColumn) {
for (const [columnId, { values, column }] of valuesByColumn) {
if (values.size === 0) continue
if (!NAME_PATTERN.test(columnName)) {
throw new Error(`Invalid column name: ${columnName}`)
if (!NAME_PATTERN.test(columnId)) {
throw new Error(`Invalid column id: ${columnId}`)
}
const valueArray = Array.from(values)
@@ -557,9 +563,9 @@ export async function checkBatchUniqueConstraintsDb(
const isStringColumn = column.type === 'string'
if (isStringColumn) {
return sql`lower(${userTableRows.data}->>${sql.raw(`'${columnName}'`)}) = ${normalizedValue}`
return sql`lower(${userTableRows.data}->>${sql.raw(`'${columnId}'`)}) = ${normalizedValue}`
}
return sql`(${userTableRows.data}->${sql.raw(`'${columnName}'`)})::jsonb = ${normalizedValue}::jsonb`
return sql`(${userTableRows.data}->${sql.raw(`'${columnId}'`)})::jsonb = ${normalizedValue}::jsonb`
})
const conflictingRows = await executor
@@ -575,7 +581,7 @@ export async function checkBatchUniqueConstraintsDb(
// Map conflicts back to batch rows
for (const conflict of conflictingRows) {
const conflictData = conflict.data as RowData
const conflictValue = conflictData[columnName]
const conflictValue = conflictData[columnId]
const normalizedConflictValue =
typeof conflictValue === 'string'
? conflictValue.toLowerCase()
@@ -583,7 +589,7 @@ export async function checkBatchUniqueConstraintsDb(
// Find which batch rows have this conflicting value
for (let i = 0; i < rows.length; i++) {
const rowValue = rows[i][columnName]
const rowValue = rows[i][columnId]
if (rowValue === null || rowValue === undefined) continue
const normalizedRowValue =
@@ -597,7 +603,7 @@ export async function checkBatchUniqueConstraintsDb(
rowErrors.push(rowError)
}
const errorMsg = `Column "${columnName}" must be unique. Value "${rowValue}" already exists in row ${conflict.position + 1}`
const errorMsg = `Column "${column.name}" must be unique. Value "${rowValue}" already exists in row ${conflict.position + 1}`
if (!rowError.errors.includes(errorMsg)) {
rowError.errors.push(errorMsg)
}
+7 -5
View File
@@ -26,6 +26,7 @@ import type {
const logger = createLogger('WorkflowGroupScheduler')
import { getColumnId } from './column-keys'
import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from './deps'
import type { DispatchLimit, DispatchMode } from './dispatcher'
@@ -742,18 +743,19 @@ export function stripGroupDeps(group: WorkflowGroup, removed: ReadonlySet<string
*/
export function validateSchema(schema: TableSchema, columnOrder: string[] | undefined): string[] {
const errors: string[] = []
const columnsByName = new Map(schema.columns.map((c) => [c.name, c]))
// Group refs and columnOrder hold stable column ids (not display names).
const columnsById = new Map(schema.columns.map((c) => [getColumnId(c), c]))
const groups = schema.workflowGroups ?? []
const groupsById = new Map(groups.map((g) => [g.id, g]))
// Reference integrity for group outputs.
const claimedColumns = new Map<string, string>() // columnName → groupId
const claimedColumns = new Map<string, string>() // columnId → groupId
for (const group of groups) {
if (group.outputs.length === 0) {
errors.push(`Workflow group "${group.name ?? group.id}" has no outputs.`)
}
for (const out of group.outputs) {
const col = columnsByName.get(out.columnName)
const col = columnsById.get(out.columnName)
if (!col) {
errors.push(
`Workflow group "${group.name ?? group.id}" references missing column "${out.columnName}".`
@@ -785,7 +787,7 @@ export function validateSchema(schema: TableSchema, columnOrder: string[] | unde
)
continue
}
if (claimedColumns.get(col.name) !== col.workflowGroupId) {
if (claimedColumns.get(getColumnId(col)) !== col.workflowGroupId) {
errors.push(
`Column "${col.name}" has workflowGroupId "${col.workflowGroupId}" but isn't in that group's outputs.`
)
@@ -804,7 +806,7 @@ export function validateSchema(schema: TableSchema, columnOrder: string[] | unde
for (const group of groups) {
const ownOutputs = new Set(group.outputs.map((o) => o.columnName))
for (const depCol of group.dependencies?.columns ?? []) {
const col = columnsByName.get(depCol)
const col = columnsById.get(depCol)
if (!col) {
errors.push(`Group "${group.name ?? group.id}" depends on missing column "${depCol}".`)
continue
+6 -2
View File
@@ -46,10 +46,13 @@ export type TableUndoAction =
}>
}
| { type: 'delete-rows'; rows: DeletedRowSnapshot[] }
| { type: 'create-column'; columnName: string; position: number }
// `columnName` is the display name (for re-create); `columnId` is the stable
// storage key used for the delete/update lookup and id-keyed metadata cleanup.
| { type: 'create-column'; columnName: string; columnId?: string; position: number }
| {
type: 'delete-column'
columnName: string
columnId?: string
columnType: ColumnDefinition['type']
columnPosition: number
columnUnique: boolean
@@ -59,7 +62,8 @@ export type TableUndoAction =
previousWidth: number | null
previousPinnedColumns: string[] | null
}
| { type: 'rename-column'; oldName: string; newName: string }
// `oldName`/`newName` are display names; `columnId` is the stable lookup key.
| { type: 'rename-column'; oldName: string; newName: string; columnId?: string }
| {
type: 'update-column-type'
columnName: string
@@ -0,0 +1,28 @@
-- Backfill stable column ids onto every user table's schema (id-keyed columns).
-- Legacy columns are grandfathered with id = name: rows are already keyed by
-- name, so adopting the name as the id leaves user_table_rows correctly keyed
-- with zero row rewrites. Idempotent: tables whose every column already has an
-- id are skipped, and per column the id is only added when absent.
--
-- Run during a deploy/quiet window: this UPDATE does not take the app's
-- per-table advisory schema lock, so a concurrent column add/rename could race.
UPDATE "user_table_definitions" AS d
SET "schema" = jsonb_set(
d."schema",
'{columns}',
(
SELECT jsonb_agg(
CASE
WHEN col ? 'id' THEN col
ELSE col || jsonb_build_object('id', col->>'name')
END
)
FROM jsonb_array_elements(d."schema"->'columns') AS col
)
)
WHERE jsonb_typeof(d."schema"->'columns') = 'array'
AND EXISTS (
SELECT 1
FROM jsonb_array_elements(d."schema"->'columns') AS c
WHERE NOT (c ? 'id')
);
File diff suppressed because it is too large Load Diff
@@ -1597,6 +1597,13 @@
"when": 1780945741000,
"tag": "0228_order_key_binary_collation",
"breakpoints": true
},
{
"idx": 229,
"version": "7",
"when": 1780946000000,
"tag": "0229_backfill_column_ids",
"breakpoints": true
}
]
}
@@ -21,6 +21,7 @@ export const featureFlagsMock = {
isEmailPasswordEnabled: false,
isSignupEmailValidationEnabled: false,
isTriggerDevEnabled: false,
isTablesFractionalOrderingEnabled: false,
isSsoEnabled: false,
isCredentialSetsEnabled: false,
isAccessControlEnabled: false,