Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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