Skip to content

Commit de16494

Browse files
committed
fix(tables): make a combined column PATCH a single transaction
Finishes the fold-in rather than pre-validating around the seam. A retype now APPLIES the constraints it already validates against — it checks empty cells for `required` and post-conversion duplicates for `unique`, so it was doing the work without persisting the result — and the route skips the separate constraint write when the type changed. A request combining a rename, a retype and constraint changes is now one locked transaction: no half of it can commit while another fails. The separate constraint write remains for requests that do not change type, which is the only case that still needs it. Deliberately still NOT merging the two service functions. They assert different lock levels (destructive vs schema-only) and only the retype needs the full row scan into memory, so a merged function would force a constraints-only toggle to materialize every row or reintroduce the branching it was meant to remove. Folding the payload in gets atomicity without either cost.
1 parent 542f966 commit de16494

4 files changed

Lines changed: 70 additions & 10 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,45 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
217217
)
218218
})
219219

220+
it('applies a rename, retype and constraints in a single write', async () => {
221+
mockCheckAccess.mockResolvedValue({
222+
ok: true,
223+
table: {
224+
workspaceId: WORKSPACE_ID,
225+
schema: { columns: [{ id: 'col_a', name: 'amount', type: 'string' }] },
226+
},
227+
})
228+
mockUpdateColumnType.mockResolvedValue({ schema: { columns: [] } })
229+
230+
const response = await patch({ name: 'total', type: 'number', required: true, unique: true })
231+
232+
expect(response.status).toBe(200)
233+
// One transaction for the whole request: no separate rename, no separate
234+
// constraint write, so no half of it can commit without the others.
235+
expect(mockRenameColumn).not.toHaveBeenCalled()
236+
expect(mockUpdateColumnConstraints).not.toHaveBeenCalled()
237+
expect(mockUpdateColumnType).toHaveBeenCalledTimes(1)
238+
expect(mockUpdateColumnType).toHaveBeenCalledWith(
239+
expect.objectContaining({
240+
newType: 'number',
241+
required: true,
242+
unique: true,
243+
newName: 'total',
244+
}),
245+
expect.any(String)
246+
)
247+
})
248+
249+
it('still runs the constraint write when the type is unchanged', async () => {
250+
mockUpdateColumnConstraints.mockResolvedValue({ schema: { columns: [] } })
251+
252+
const response = await patch({ required: true })
253+
254+
expect(response.status).toBe(200)
255+
expect(mockUpdateColumnConstraints).toHaveBeenCalledTimes(1)
256+
expect(mockUpdateColumnType).not.toHaveBeenCalled()
257+
})
258+
220259
it('rejects constraint changes on a workflow-output column before any write', async () => {
221260
mockCheckAccess.mockResolvedValue({
222261
ok: true,

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
135135
const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName
136136
// The constraints write below is a separate, unconditional step, so it is
137137
// 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 } : {}
141138
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
139+
// A retype applies and validates the constraints itself, so the separate
140+
// constraint write only runs when the type is unchanged. The rename rides
141+
// whichever write actually runs last.
142+
const constraintsWriteRuns =
143+
!typeChanging && (updates.required !== undefined || updates.unique !== undefined)
144+
const renameWithTypedWrite =
145+
updates.name && !constraintsWriteRuns ? { newName: updates.name } : {}
142146

143147
// Every write below is its own locked transaction, so one that is going to
144148
// fail leaves the earlier ones committed. These guards reject the knowable
@@ -246,7 +250,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
246250
)
247251
}
248252

249-
if (updates.required !== undefined || updates.unique !== undefined) {
253+
// Skipped when the type changed: that write already applied and validated
254+
// these, in one transaction with the conversion.
255+
if (!typeChanging && (updates.required !== undefined || updates.unique !== undefined)) {
250256
updatedTable = await updateColumnConstraints(
251257
{
252258
tableId,

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,14 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
169169
const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName
170170
// The constraints write below is a separate, unconditional step, so it is
171171
// 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 } : {}
175172
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
173+
// A retype applies and validates the constraints itself, so the separate
174+
// constraint write only runs when the type is unchanged. The rename rides
175+
// whichever write actually runs last.
176+
const constraintsWriteRuns =
177+
!typeChanging && (updates.required !== undefined || updates.unique !== undefined)
178+
const renameWithTypedWrite =
179+
updates.name && !constraintsWriteRuns ? { newName: updates.name } : {}
176180

177181
// Every write below is its own locked transaction, so one that is going to
178182
// fail leaves the earlier ones committed. These guards reject the knowable
@@ -280,7 +284,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
280284
)
281285
}
282286

283-
if (updates.required !== undefined || updates.unique !== undefined) {
287+
// Skipped when the type changed: that write already applied and validated
288+
// these, in one transaction with the conversion.
289+
if (!typeChanging && (updates.required !== undefined || updates.unique !== undefined)) {
284290
updatedTable = await updateColumnConstraints(
285291
{
286292
tableId,

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,19 @@ function buildConvertedColumn(
571571
// cannot ride through `...rest` onto a target that does not own it — which
572572
// `validateColumnDefinition` would then reject on every later write.
573573
const rest = omit(column, [...TYPE_SPECIFIC_COLUMN_KEYS]) as ColumnDefinition
574+
// Constraints arriving with the retype are APPLIED here, not left to a second
575+
// transaction. `updateColumnType` already validates against them (empty cells
576+
// for `required`, post-conversion duplicates for `unique`), so applying them
577+
// in the same write is what makes a combined request all-or-nothing.
578+
const withConstraints: ColumnDefinition = {
579+
...rest,
580+
...(data.required !== undefined ? { required: data.required } : {}),
581+
...(data.unique !== undefined ? { unique: data.unique } : {}),
582+
}
574583

575584
if (isSelectType) {
576585
return {
577-
...rest,
586+
...withConstraints,
578587
type: data.newType,
579588
options: data.options ?? column.options,
580589
...(targetMultiple ? { multiple: true } : {}),
@@ -593,7 +602,7 @@ function buildConvertedColumn(
593602
// future type.
594603
const definition = columnTypeById(data.newType)
595604
const owned = new Set<string>(definition.ownedMetadata)
596-
const carried: ColumnDefinition = { ...rest, type: data.newType }
605+
const carried: ColumnDefinition = { ...withConstraints, type: data.newType }
597606
for (const key of TYPE_SPECIFIC_COLUMN_KEYS) {
598607
if (!owned.has(key)) continue
599608
const value = data[key] ?? column[key]

0 commit comments

Comments
 (0)