Skip to content

Commit 3d3ea8c

Browse files
committed
fix(tables): fold a rename into the write it accompanies
Closes the last partial-update window, properly rather than by pre-checking around it. A rename is metadata-only — `renameColumn`'s own comment says so: rows, metadata, and workflow-group refs all key on the stable column id, so it is a pure schema write. Nothing forced it to be its own transaction. Running it separately is what created the window: whichever half committed first survived a failure in the other, and no pre-flight guard can close a concurrent collision because only the write itself sees authoritative state. The four column writes now accept an optional `newName` and apply it through one shared `applyPendingRename`, which validates the name shape and checks the collision against the very schema snapshot that write is landing in. A combined request rides the rename on whichever write runs last, so both halves commit together or neither does — a concurrent claim on the name now aborts the whole transaction instead of leaving the other change applied. The routes also address every write by the column's stable id rather than its name, so folding a rename into one write cannot break the next one's lookup. A rename with nothing to ride on still runs standalone. What remains partial is a type write followed by a failing constraints write — two independently locked transactions, pre-existing, and untouched by this PR.
1 parent cfdf38f commit 3d3ea8c

6 files changed

Lines changed: 173 additions & 39 deletions

File tree

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
113113
expect(mockRenameColumn).not.toHaveBeenCalled()
114114
})
115115

116+
it('renames standalone when there is no other write to ride on', async () => {
117+
mockRenameColumn.mockResolvedValue({ schema: { columns: [] } })
118+
119+
const response = await patch({ name: 'renamed' })
120+
121+
expect(response.status).toBe(200)
122+
expect(mockRenameColumn).toHaveBeenCalledWith(
123+
expect.objectContaining({ oldName: 'col_a', newName: 'renamed' }),
124+
expect.any(String)
125+
)
126+
})
127+
116128
it('leaves the column untouched when a typed write fails', async () => {
117129
mockCheckAccess.mockResolvedValue({
118130
ok: true,
@@ -177,7 +189,7 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
177189
expect(mockRenameColumn).not.toHaveBeenCalled()
178190
})
179191

180-
it('renames only after the typed write succeeds', async () => {
192+
it('folds a rename into the typed write instead of running it separately', async () => {
181193
mockCheckAccess.mockResolvedValue({
182194
ok: true,
183195
table: {
@@ -190,15 +202,13 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
190202
const response = await patch({ name: 'renamed', currencyCode: 'eur' })
191203

192204
expect(response.status).toBe(200)
193-
expect(mockRenameColumn).toHaveBeenCalledTimes(1)
205+
// One transaction, not two: the rename rides along with the currency write,
206+
// so neither half can commit without the other.
207+
expect(mockRenameColumn).not.toHaveBeenCalled()
194208
expect(mockUpdateColumnCurrency).toHaveBeenCalledWith(
195-
// Targets the column's CURRENT name: the rename has not run yet. The
196-
// contract upper-cases the code on the way in.
197-
expect.objectContaining({ columnName: 'amount', currencyCode: 'EUR' }),
209+
// Addressed by stable id; the contract upper-cases the code on the way in.
210+
expect.objectContaining({ columnName: 'col_a', currencyCode: 'EUR', newName: 'renamed' }),
198211
expect.any(String)
199212
)
200-
expect(mockUpdateColumnCurrency.mock.invocationCallOrder[0]).toBeLessThan(
201-
mockRenameColumn.mock.invocationCallOrder[0]
202-
)
203213
})
204214
})

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
updateColumnOptions,
2020
updateColumnType,
2121
} from '@/lib/table'
22-
import { columnMatchesRef } from '@/lib/table/column-keys'
22+
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
2323
import { columnTypeById } from '@/lib/table/column-types'
2424
import { isSupportedCurrencyCode } from '@/lib/table/currency'
2525
import {
@@ -130,6 +130,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
130130
const currentColumn = table.schema.columns.find((c) =>
131131
columnMatchesRef(c, validated.columnName)
132132
)
133+
// Address every write below by the stable id, not the name: a rename folded
134+
// into one of them must not break the next one's lookup.
135+
const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName
136+
// The constraints write below is a separate, unconditional step, so it is
137+
// the last one whenever it runs — that is the write the rename rides on.
138+
const constraintsChanging = updates.required !== undefined || updates.unique !== undefined
139+
const renameWithTypedWrite =
140+
updates.name && !constraintsChanging ? { newName: updates.name } : {}
133141
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
134142

135143
// Every write below is its own locked transaction, so one that is going to
@@ -185,14 +193,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
185193
updatedTable = await updateColumnType(
186194
{
187195
tableId,
188-
columnName: validated.columnName,
196+
columnName: columnRef,
189197
newType: updates.type as NonNullable<typeof updates.type>,
190198
...(updates.options !== undefined ? { options: updates.options } : {}),
191199
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
192200
...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}),
193201
// Forwarded so the conversion validates against the constraint this
194202
// same request is about to set, not the column's current one.
195203
...(updates.required !== undefined ? { required: updates.required } : {}),
204+
...renameWithTypedWrite,
196205
},
197206
requestId
198207
)
@@ -203,21 +212,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
203212
updatedTable = await updateColumnCurrency(
204213
{
205214
tableId,
206-
columnName: validated.columnName,
215+
columnName: columnRef,
207216
currencyCode: updates.currencyCode,
217+
...renameWithTypedWrite,
208218
},
209219
requestId
210220
)
211221
} else if (updates.options !== undefined || updates.multiple !== undefined) {
212222
updatedTable = await updateColumnOptions(
213223
{
214224
tableId,
215-
columnName: validated.columnName,
225+
columnName: columnRef,
216226
options: updates.options ?? currentColumn?.options ?? [],
217227
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
218228
// Forwarded so the removal guard validates against the constraint this
219229
// same request is about to set, not the column's current one.
220230
...(updates.required !== undefined ? { required: updates.required } : {}),
231+
...renameWithTypedWrite,
221232
},
222233
requestId
223234
)
@@ -227,23 +238,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
227238
updatedTable = await updateColumnConstraints(
228239
{
229240
tableId,
230-
columnName: validated.columnName,
241+
columnName: columnRef,
231242
...(updates.required !== undefined ? { required: updates.required } : {}),
232243
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
244+
...(updates.name ? { newName: updates.name } : {}),
233245
},
234246
requestId
235247
)
236248
}
237249

238-
// Rename LAST, on purpose. Each write above is its own locked transaction,
239-
// and the pre-flight guards read a schema snapshot — so a concurrent change
240-
// can still make one of them fail. Renaming first meant that failure
241-
// returned an error with the rename already committed. Going last, a failed
242-
// typed write leaves the column untouched, and a failed rename leaves the
243-
// typed change applied under the old name, which is the recoverable half.
244-
if (updates.name) {
250+
// A rename rides along with the LAST write above, inside that write's
251+
// transaction — a rename is metadata-only (rows key on the stable column
252+
// id), so nothing forces it to be its own write, and folding it in is what
253+
// stops a combined request from committing one half and then failing. Only
254+
// a rename with nothing to ride on runs standalone.
255+
if (updates.name && !updatedTable) {
245256
updatedTable = await renameColumn(
246-
{ tableId, oldName: validated.columnName, newName: updates.name },
257+
{ tableId, oldName: columnRef, newName: updates.name },
247258
requestId
248259
)
249260
}

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
updateColumnOptions,
1919
updateColumnType,
2020
} from '@/lib/table'
21-
import { columnMatchesRef } from '@/lib/table/column-keys'
21+
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
2222
import { columnTypeById } from '@/lib/table/column-types'
2323
import { isSupportedCurrencyCode } from '@/lib/table/currency'
2424
import {
@@ -164,6 +164,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
164164
const currentColumn = table.schema.columns.find((c) =>
165165
columnMatchesRef(c, validated.columnName)
166166
)
167+
// Address every write below by the stable id, not the name: a rename folded
168+
// into one of them must not break the next one's lookup.
169+
const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName
170+
// The constraints write below is a separate, unconditional step, so it is
171+
// the last one whenever it runs — that is the write the rename rides on.
172+
const constraintsChanging = updates.required !== undefined || updates.unique !== undefined
173+
const renameWithTypedWrite =
174+
updates.name && !constraintsChanging ? { newName: updates.name } : {}
167175
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
168176

169177
// Every write below is its own locked transaction, so one that is going to
@@ -219,14 +227,15 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
219227
updatedTable = await updateColumnType(
220228
{
221229
tableId,
222-
columnName: validated.columnName,
230+
columnName: columnRef,
223231
newType: updates.type as NonNullable<typeof updates.type>,
224232
...(updates.options !== undefined ? { options: updates.options } : {}),
225233
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
226234
...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}),
227235
// Forwarded so the conversion validates against the constraint this
228236
// same request is about to set, not the column's current one.
229237
...(updates.required !== undefined ? { required: updates.required } : {}),
238+
...renameWithTypedWrite,
230239
},
231240
requestId
232241
)
@@ -237,21 +246,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
237246
updatedTable = await updateColumnCurrency(
238247
{
239248
tableId,
240-
columnName: validated.columnName,
249+
columnName: columnRef,
241250
currencyCode: updates.currencyCode,
251+
...renameWithTypedWrite,
242252
},
243253
requestId
244254
)
245255
} else if (updates.options !== undefined || updates.multiple !== undefined) {
246256
updatedTable = await updateColumnOptions(
247257
{
248258
tableId,
249-
columnName: validated.columnName,
259+
columnName: columnRef,
250260
options: updates.options ?? currentColumn?.options ?? [],
251261
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
252262
// Forwarded so the removal guard validates against the constraint this
253263
// same request is about to set, not the column's current one.
254264
...(updates.required !== undefined ? { required: updates.required } : {}),
265+
...renameWithTypedWrite,
255266
},
256267
requestId
257268
)
@@ -261,23 +272,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
261272
updatedTable = await updateColumnConstraints(
262273
{
263274
tableId,
264-
columnName: validated.columnName,
275+
columnName: columnRef,
265276
...(updates.required !== undefined ? { required: updates.required } : {}),
266277
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
278+
...(updates.name ? { newName: updates.name } : {}),
267279
},
268280
requestId
269281
)
270282
}
271283

272-
// Rename LAST, on purpose. Each write above is its own locked transaction,
273-
// and the pre-flight guards read a schema snapshot — so a concurrent change
274-
// can still make one of them fail. Renaming first meant that failure
275-
// returned an error with the rename already committed. Going last, a failed
276-
// typed write leaves the column untouched, and a failed rename leaves the
277-
// typed change applied under the old name, which is the recoverable half.
278-
if (updates.name) {
284+
// A rename rides along with the LAST write above, inside that write's
285+
// transaction — a rename is metadata-only (rows key on the stable column
286+
// id), so nothing forces it to be its own write, and folding it in is what
287+
// stops a combined request from committing one half and then failing. Only
288+
// a rename with nothing to ride on runs standalone.
289+
if (updates.name && !updatedTable) {
279290
updatedTable = await renameColumn(
280-
{ tableId, oldName: validated.columnName, newName: updates.name },
291+
{ tableId, oldName: columnRef, newName: updates.name },
281292
requestId
282293
)
283294
}

apps/sim/lib/table/__tests__/column-conversion.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
* coverage and every case below was a shipped defect caught in review.
66
*/
77
import { describe, expect, it } from 'vitest'
8-
import { isValueCompatibleWithType, selectValueForConversion } from '@/lib/table/columns/service'
8+
import {
9+
applyPendingRename,
10+
isValueCompatibleWithType,
11+
selectValueForConversion,
12+
} from '@/lib/table/columns/service'
913
import type { ColumnDefinition, SelectOption } from '@/lib/table/types'
1014

1115
const OPTIONS: SelectOption[] = [
@@ -161,3 +165,32 @@ describe('blank cells during conversion', () => {
161165
expect(isValueCompatibleWithType('', 'select', OPTIONS, false, true)).toBe(false)
162166
})
163167
})
168+
169+
describe('rename folded into another write', () => {
170+
// A rename is metadata-only — rows key on the stable column id — so nothing
171+
// forces it to be its own transaction. Folding it into whichever write a
172+
// request already carries is what makes a combined PATCH all-or-nothing: the
173+
// name collision is detected against the same schema snapshot the other
174+
// change is being applied to, and both abort together.
175+
it('rejects a collision against the schema the write is landing in', () => {
176+
const columns: ColumnDefinition[] = [
177+
{ id: 'col_a', name: 'amount', type: 'currency' },
178+
{ id: 'col_b', name: 'taken', type: 'string' },
179+
]
180+
expect(() => applyPendingRename(columns, 0, 'taken')).toThrow(/already exists/)
181+
expect(() => applyPendingRename(columns, 0, 'TAKEN')).toThrow(/already exists/)
182+
})
183+
184+
it('applies a valid rename and is a no-op without one', () => {
185+
const columns: ColumnDefinition[] = [{ id: 'col_a', name: 'amount', type: 'currency' }]
186+
expect(applyPendingRename(columns, 0, 'total').name).toBe('total')
187+
expect(applyPendingRename(columns, 0, undefined)).toBe(columns[0])
188+
expect(applyPendingRename(columns, 0, 'amount')).toBe(columns[0])
189+
})
190+
191+
it('rejects a name the column-name rules forbid', () => {
192+
const columns: ColumnDefinition[] = [{ id: 'col_a', name: 'amount', type: 'currency' }]
193+
expect(() => applyPendingRename(columns, 0, '1bad')).toThrow(/must start with/)
194+
expect(() => applyPendingRename(columns, 0, 'a'.repeat(200))).toThrow(/maximum length/)
195+
})
196+
})

apps/sim/lib/table/columns/service.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,43 @@ export async function deleteColumns(
485485
return def
486486
}
487487

488+
/**
489+
* Validates a pending rename against the schema it will land in, and returns
490+
* the renamed column.
491+
*
492+
* Exists so a rename can be folded into whatever OTHER column write a request
493+
* carries, inside that write's transaction. Each write is its own locked
494+
* transaction, so a standalone rename alongside one of them means either order
495+
* can commit and then fail — and since a rename is metadata-only (rows key on
496+
* the stable column id), there is nothing forcing it to be its own write.
497+
*
498+
* Returns the column unchanged when there is no rename to apply. Exported so
499+
* the collision and name-shape rules are testable without a transaction.
500+
*/
501+
export function applyPendingRename(
502+
columns: ColumnDefinition[],
503+
columnIndex: number,
504+
newName: string | undefined
505+
): ColumnDefinition {
506+
const column = columns[columnIndex]
507+
if (newName === undefined || newName === column.name) return column
508+
509+
if (!NAME_PATTERN.test(newName)) {
510+
throw new Error(
511+
`Invalid column name "${newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.`
512+
)
513+
}
514+
if (newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) {
515+
throw new Error(
516+
`Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)`
517+
)
518+
}
519+
if (columns.some((c, i) => i !== columnIndex && c.name.toLowerCase() === newName.toLowerCase())) {
520+
throw new Error(`Column "${newName}" already exists`)
521+
}
522+
return { ...column, name: newName }
523+
}
524+
488525
/**
489526
* The column definition a retype produces: prior per-type metadata dropped,
490527
* then only what the TARGET type declares it owns carried forward, then that
@@ -694,7 +731,10 @@ export async function updateColumnType(
694731
)
695732
}
696733

697-
const updatedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c))
734+
const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c))
735+
const updatedColumns = renamedColumns.map((c, i) =>
736+
i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c
737+
)
698738

699739
const columnValidation = validateColumnDefinition(updatedColumns[columnIndex])
700740
if (!columnValidation.valid) {
@@ -798,7 +838,7 @@ export async function updateColumnConstraints(
798838
}
799839
}
800840

801-
const updatedColumns = schema.columns.map((c, i) =>
841+
const withConstraints = schema.columns.map((c, i) =>
802842
i === columnIndex
803843
? {
804844
...c,
@@ -807,6 +847,9 @@ export async function updateColumnConstraints(
807847
}
808848
: c
809849
)
850+
const updatedColumns = withConstraints.map((c, i) =>
851+
i === columnIndex ? applyPendingRename(withConstraints, columnIndex, data.newName) : c
852+
)
810853
const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
811854
const now = new Date()
812855

@@ -858,7 +901,10 @@ export async function updateColumnOptions(
858901
throw new Error(`Invalid column: ${columnValidation.errors.join('; ')}`)
859902
}
860903

861-
const updatedColumns = schema.columns.map((c, i) => (i === columnIndex ? updatedColumn : c))
904+
const withOptions = schema.columns.map((c, i) => (i === columnIndex ? updatedColumn : c))
905+
const updatedColumns = withOptions.map((c, i) =>
906+
i === columnIndex ? applyPendingRename(withOptions, columnIndex, data.newName) : c
907+
)
862908
const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
863909
const now = new Date()
864910

@@ -1024,7 +1070,10 @@ export async function updateColumnCurrency(
10241070
return table
10251071
}
10261072

1027-
const updatedColumns = schema.columns.map((c, i) => (i === columnIndex ? updatedColumn : c))
1073+
const withCurrency = schema.columns.map((c, i) => (i === columnIndex ? updatedColumn : c))
1074+
const updatedColumns = withCurrency.map((c, i) =>
1075+
i === columnIndex ? applyPendingRename(withCurrency, columnIndex, data.newName) : c
1076+
)
10281077
const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
10291078
const now = new Date()
10301079

0 commit comments

Comments
 (0)