Skip to content
22 changes: 16 additions & 6 deletions apps/sim/app/api/table/[tableId]/import/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
])
})

Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/app/api/table/[tableId]/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
coerceRowsForTable,
createCsvParser,
dispatchAfterBatchInsert,
generateColumnId,
importAppendRows,
importReplaceRows,
inferColumnType,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/table/import-csv/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 14 additions & 5 deletions apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
49 changes: 37 additions & 12 deletions apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
})
Expand All @@ -70,7 +81,7 @@ async function handleBatchInsert(
const insertedRows = await batchInsertRows(
{
tableId,
rows: validated.rows as RowData[],
rows,
workspaceId: validated.workspaceId,
userId,
},
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 },
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
}

Expand Down
Loading