Skip to content

Commit 77abb11

Browse files
feat(tables): stable column ids for metadata-only rename
1 parent 24a6086 commit 77abb11

40 files changed

Lines changed: 18429 additions & 520 deletions

File tree

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,14 @@ describe('POST /api/table/[tableId]/import', () => {
393393
)
394394
expect(response.status).toBe(200)
395395
expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
396-
expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
396+
expect(appendAdditions()).toEqual([
397+
expect.objectContaining({ name: 'email', type: 'string' }),
398+
])
399+
// Existing columns have no id (legacy) → keyed by name; the new `email`
400+
// column was assigned id `col_short-id` (mocked generateShortId).
397401
expect(appendRows()).toEqual([
398-
{ name: 'Alice', age: 30, email: 'a@x.io' },
399-
{ name: 'Bob', age: 40, email: 'b@x.io' },
402+
{ name: 'Alice', age: 30, 'col_short-id': 'a@x.io' },
403+
{ name: 'Bob', age: 40, 'col_short-id': 'b@x.io' },
400404
])
401405
})
402406

@@ -408,7 +412,9 @@ describe('POST /api/table/[tableId]/import', () => {
408412
})
409413
)
410414
expect(response.status).toBe(200)
411-
expect(appendAdditions()).toEqual([{ name: 'score', type: 'number' }])
415+
expect(appendAdditions()).toEqual([
416+
expect.objectContaining({ name: 'score', type: 'number' }),
417+
])
412418
})
413419

414420
it('dedupes when sanitized name collides with an existing column', async () => {
@@ -431,7 +437,9 @@ describe('POST /api/table/[tableId]/import', () => {
431437
})
432438
)
433439
expect(response.status).toBe(200)
434-
expect(appendAdditions()).toEqual([{ name: 'Email_2', type: 'string' }])
440+
expect(appendAdditions()).toEqual([
441+
expect.objectContaining({ name: 'Email_2', type: 'string' }),
442+
])
435443
})
436444

437445
it('returns 400 when createColumns references a header not in the CSV', async () => {
@@ -494,7 +502,9 @@ describe('POST /api/table/[tableId]/import', () => {
494502
})
495503
)
496504
// Route forwarded the column addition into the (now atomic) import op.
497-
expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
505+
expect(appendAdditions()).toEqual([
506+
expect.objectContaining({ name: 'email', type: 'string' }),
507+
])
498508
expect(response.status).toBe(400)
499509
const data = await response.json()
500510
expect(data.success).toBeUndefined()

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import {
2222
type CsvHeaderMapping,
2323
CsvImportValidationError,
2424
coerceRowsForTable,
25+
collectColumnIds,
2526
createCsvParser,
2627
dispatchAfterBatchInsert,
28+
generateColumnId,
2729
importAppendRows,
2830
importReplaceRows,
2931
inferColumnType,
@@ -176,7 +178,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
176178

177179
let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema)
178180
let prospectiveTable: TableDefinition = table
179-
const additions: { name: string; type: string }[] = []
181+
const additions: { id?: string; name: string; type: string }[] = []
180182

181183
if (createColumns && createColumns.length > 0) {
182184
const headerSet = new Set(headers)
@@ -191,6 +193,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
191193
}
192194

193195
const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase()))
196+
const takenIds = new Set(collectColumnIds(table.schema))
194197
const updatedMapping: CsvHeaderMapping = { ...effectiveMapping }
195198
const newColumns: TableSchema['columns'] = []
196199

@@ -204,8 +207,13 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
204207
}
205208
usedNames.add(columnName.toLowerCase())
206209
const inferredType = inferColumnType(rows.map((r) => r[header]))
207-
additions.push({ name: columnName, type: inferredType })
210+
// Pre-assign the id so the prospective schema (used to coerce rows) and
211+
// the persisted column (created in importAppendRows) share the same key.
212+
const id = generateColumnId(takenIds)
213+
takenIds.add(id)
214+
additions.push({ id, name: columnName, type: inferredType })
208215
newColumns.push({
216+
id,
209217
name: columnName,
210218
type: inferredType as TableSchema['columns'][number]['type'],
211219
required: false,

apps/sim/app/api/table/import-csv/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
137137
},
138138
requestId
139139
)
140-
return { table, schema, headerToColumn: inferred.headerToColumn }
140+
// Coerce against the *created* schema so rows key by the ids `createTable`
141+
// assigned (the local `schema` is the id-less inferred one).
142+
return { table, schema: table.schema, headerToColumn: inferred.headerToColumn }
141143
}
142144

143145
let state: ImportState | null = null

apps/sim/app/api/table/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ export const DeleteColumnSchema = deleteTableColumnBodySchema
200200

201201
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
202202
return {
203+
// Preserve the stable column id — it's the row-data storage key, so dropping
204+
// it makes clients fall back to `name` and miss id-keyed cell values.
205+
...(col.id ? { id: col.id } : {}),
203206
name: col.name,
204207
type: col.type,
205208
required: col.required ?? false,

apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@ import {
1212
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
1313
import { generateRequestId } from '@/lib/core/utils/request'
1414
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15-
import type { RowData } from '@/lib/table'
16-
import { updateRow } from '@/lib/table'
15+
import type { RowData, TableSchema } from '@/lib/table'
16+
import {
17+
buildIdByName,
18+
buildNameById,
19+
rowDataIdToName,
20+
rowDataNameToId,
21+
updateRow,
22+
} from '@/lib/table'
1723
import { accessError, checkAccess } from '@/app/api/table/utils'
1824
import {
1925
checkRateLimit,
@@ -81,12 +87,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou
8187
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
8288
}
8389

90+
const nameById = buildNameById(result.table.schema as TableSchema)
8491
return NextResponse.json({
8592
success: true,
8693
data: {
8794
row: {
8895
id: row.id,
89-
data: row.data,
96+
data: rowDataIdToName(row.data as RowData, nameById),
9097
position: row.position,
9198
createdAt:
9299
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
@@ -129,11 +136,13 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
129136
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
130137
}
131138

139+
const idByName = buildIdByName(table.schema as TableSchema)
140+
const nameById = buildNameById(table.schema as TableSchema)
132141
const updatedRow = await updateRow(
133142
{
134143
tableId,
135144
rowId,
136-
data: validated.data as RowData,
145+
data: rowDataNameToId(validated.data as RowData, idByName),
137146
workspaceId: validated.workspaceId,
138147
},
139148
table,
@@ -153,7 +162,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
153162
data: {
154163
row: {
155164
id: updatedRow.id,
156-
data: updatedRow.data,
165+
data: rowDataIdToName(updatedRow.data, nameById),
157166
position: updatedRow.position,
158167
createdAt:
159168
updatedRow.createdAt instanceof Date

apps/sim/app/api/v1/tables/[tableId]/rows/route.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1818
import type { Filter, RowData, TableSchema } from '@/lib/table'
1919
import {
2020
batchInsertRows,
21+
buildIdByName,
22+
buildNameById,
2123
deleteRowsByFilter,
2224
deleteRowsByIds,
25+
filterNamesToIds,
2326
insertRow,
27+
rowDataIdToName,
28+
rowDataNameToId,
29+
sortNamesToIds,
2430
updateRowsByFilter,
2531
validateBatchRows,
2632
validateRowData,
@@ -59,8 +65,13 @@ async function handleBatchInsert(
5965
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
6066
}
6167

68+
// External callers key row data by column name; storage keys by id.
69+
const idByName = buildIdByName(table.schema as TableSchema)
70+
const nameById = buildNameById(table.schema as TableSchema)
71+
const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName))
72+
6273
const validation = await validateBatchRows({
63-
rows: validated.rows as RowData[],
74+
rows,
6475
schema: table.schema as TableSchema,
6576
tableId,
6677
})
@@ -70,7 +81,7 @@ async function handleBatchInsert(
7081
const insertedRows = await batchInsertRows(
7182
{
7283
tableId,
73-
rows: validated.rows as RowData[],
84+
rows,
7485
workspaceId: validated.workspaceId,
7586
userId,
7687
},
@@ -83,7 +94,7 @@ async function handleBatchInsert(
8394
data: {
8495
rows: insertedRows.map((r) => ({
8596
id: r.id,
86-
data: r.data,
97+
data: rowDataIdToName(r.data, nameById),
8798
position: r.position,
8899
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
89100
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
@@ -150,11 +161,19 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
150161
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
151162
}
152163

164+
// Translate name-keyed filter/sort fields → column ids; translate rows back.
165+
const idByName = buildIdByName(table.schema as TableSchema)
166+
const nameById = buildNameById(table.schema as TableSchema)
167+
const filter = validated.filter
168+
? filterNamesToIds(validated.filter as Filter, idByName)
169+
: undefined
170+
const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined
171+
153172
const result = await queryRows(
154173
table,
155174
{
156-
filter: validated.filter as Filter | undefined,
157-
sort: validated.sort,
175+
filter,
176+
sort,
158177
limit: validated.limit,
159178
offset: validated.offset,
160179
includeTotal: validated.includeTotal,
@@ -168,7 +187,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
168187
data: {
169188
rows: result.rows.map((r) => ({
170189
id: r.id,
171-
data: r.data,
190+
data: rowDataIdToName(r.data, nameById),
172191
position: r.position,
173192
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
174193
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
@@ -229,7 +248,9 @@ export const POST = withRouteHandler(
229248
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
230249
}
231250

232-
const rowData = validated.data as RowData
251+
const idByName = buildIdByName(table.schema as TableSchema)
252+
const nameById = buildNameById(table.schema as TableSchema)
253+
const rowData = rowDataNameToId(validated.data as RowData, idByName)
233254

234255
const validation = await validateRowData({
235256
rowData,
@@ -254,7 +275,7 @@ export const POST = withRouteHandler(
254275
data: {
255276
row: {
256277
id: row.id,
257-
data: row.data,
278+
data: rowDataIdToName(row.data, nameById),
258279
position: row.position,
259280
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
260281
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
@@ -312,7 +333,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
312333
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
313334
}
314335

315-
const sizeValidation = validateRowSize(validated.data as RowData)
336+
const idByName = buildIdByName(table.schema as TableSchema)
337+
const patchData = rowDataNameToId(validated.data as RowData, idByName)
338+
339+
const sizeValidation = validateRowSize(patchData)
316340
if (!sizeValidation.valid) {
317341
return NextResponse.json(
318342
{ error: 'Validation error', details: sizeValidation.errors },
@@ -323,8 +347,8 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
323347
const result = await updateRowsByFilter(
324348
table,
325349
{
326-
filter: validated.filter as Filter,
327-
data: validated.data as RowData,
350+
filter: filterNamesToIds(validated.filter as Filter, idByName),
351+
data: patchData,
328352
limit: validated.limit,
329353
},
330354
requestId
@@ -424,10 +448,11 @@ export const DELETE = withRouteHandler(
424448
})
425449
}
426450

451+
const idByName = buildIdByName(table.schema as TableSchema)
427452
const result = await deleteRowsByFilter(
428453
table,
429454
{
430-
filter: validated.filter as Filter,
455+
filter: filterNamesToIds(validated.filter as Filter, idByName),
431456
limit: validated.limit,
432457
},
433458
requestId

apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@ import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables'
55
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import type { RowData } from '@/lib/table'
9-
import { upsertRow } from '@/lib/table'
8+
import type { RowData, TableSchema } from '@/lib/table'
9+
import {
10+
buildIdByName,
11+
buildNameById,
12+
rowDataIdToName,
13+
rowDataNameToId,
14+
upsertRow,
15+
} from '@/lib/table'
1016
import { accessError, checkAccess } from '@/app/api/table/utils'
1117
import {
1218
checkRateLimit,
@@ -51,11 +57,13 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
5157
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
5258
}
5359

60+
const idByName = buildIdByName(table.schema as TableSchema)
61+
const nameById = buildNameById(table.schema as TableSchema)
5462
const upsertResult = await upsertRow(
5563
{
5664
tableId,
5765
workspaceId: validated.workspaceId,
58-
data: validated.data as RowData,
66+
data: rowDataNameToId(validated.data as RowData, idByName),
5967
userId,
6068
conflictTarget: validated.conflictTarget,
6169
},
@@ -68,7 +76,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
6876
data: {
6977
row: {
7078
id: upsertResult.row.id,
71-
data: upsertResult.row.data,
79+
data: rowDataIdToName(upsertResult.row.data, nameById),
7280
createdAt:
7381
upsertResult.row.createdAt instanceof Date
7482
? upsertResult.row.createdAt.toISOString()

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ function ColumnConfigBody({
116116
return
117117
}
118118

119-
const renamed = trimmedName !== config.columnName
119+
// `config.columnName` is the column id; compare against the current display
120+
// name to detect an actual rename.
121+
const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
120122
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
121123
const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput
122124

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ export function ExpandedCellPopover({
4949
// workflow columns share `name` across siblings, so prefer `key` when set.
5050
const matchByKey = expandedCell.columnKey
5151
? (c: DisplayColumn) => c.key === expandedCell.columnKey
52-
: (c: DisplayColumn) => c.name === expandedCell.columnName
52+
: (c: DisplayColumn) => c.key === expandedCell.columnName
5353
const column = columns.find(matchByKey)
5454
if (!row || !column) return null
5555
const colIndex = columns.findIndex(matchByKey)
56-
return { row, column, colIndex, value: row.data[column.name] }
56+
return { row, column, colIndex, value: row.data[column.key] }
5757
}, [expandedCell, rows, columns])
5858

5959
const isBooleanCell = target?.column.type === 'boolean'
@@ -142,7 +142,7 @@ export function ExpandedCellPopover({
142142
// Fall back to the raw draft for non-date columns, matching the inline editor.
143143
const raw = displayToStorage(draftValue) ?? draftValue
144144
const cleaned = cleanCellValue(raw, target.column)
145-
onSave(target.row.id, target.column.name, cleaned, 'blur')
145+
onSave(target.row.id, target.column.key, cleaned, 'blur')
146146
onClose()
147147
}
148148

0 commit comments

Comments
 (0)