Skip to content

Commit 1de0f14

Browse files
committed
fix(tables): validate a retype's unique against the values it writes
Validated the last partial-update seam with a focused investigation rather than assuming. The answer was split. `required` is already safe: `updateColumnType` runs the same `countEmptyCells` against the constraint the request is about to set, which is why that check exists. `unique` was not, and the reachable case commits the unrecoverable half. A text column holding "5" and "5.0", PATCHed with {type: number, unique: true}: the conversion succeeds and coerces both to 5, then the separate constraint write finds duplicates and 400s — with the column already numeric and "5.0" irreversibly rewritten. A pre-scan of the raw text finds nothing; the conversion is what manufactures the duplicate. The retype now carries `unique` and checks it after the write-back, against the values it just wrote. Constraint changes on a workflow-output column were the same shape — rejected by the constraint write, after a type change had committed. Now rejected in the route's pre-flight block, before any write. The duplicate scan is extracted and shared between both paths for the same reason `countEmptyCells` is: two copies of one rule is the drift that produced the original required-check bug. Deliberately NOT merging `updateColumnType` and `updateColumnConstraints`. They assert different lock levels (destructive vs schema-only) and only the retype needs the full row scan, so merging would either force a constraints-only toggle to materialize every row or reintroduce the branching it was meant to remove. With both reachable failures pre-validated, what remains at the seam is concurrent races no in-process check can close.
1 parent 0913728 commit 1de0f14

5 files changed

Lines changed: 110 additions & 5 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,50 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
170170
expect(mockRenameColumn).not.toHaveBeenCalled()
171171
})
172172

173+
it('forwards unique to the retype so it validates post-conversion values', async () => {
174+
mockCheckAccess.mockResolvedValue({
175+
ok: true,
176+
table: {
177+
workspaceId: WORKSPACE_ID,
178+
schema: { columns: [{ id: 'col_a', name: 'amount', type: 'string' }] },
179+
},
180+
})
181+
mockUpdateColumnType.mockResolvedValue({ schema: { columns: [] } })
182+
mockUpdateColumnConstraints.mockResolvedValue({ schema: { columns: [] } })
183+
184+
const response = await patch({ type: 'number', unique: true })
185+
186+
expect(response.status).toBe(200)
187+
// The conversion itself can manufacture duplicates ("5" and "5.0" both
188+
// coerce to 5), so the retype has to see `unique` — discovering it in the
189+
// separate constraint write would report an error with the conversion
190+
// already committed and the original text irrecoverably rewritten.
191+
expect(mockUpdateColumnType).toHaveBeenCalledWith(
192+
expect.objectContaining({ newType: 'number', unique: true }),
193+
expect.any(String)
194+
)
195+
})
196+
197+
it('rejects constraint changes on a workflow-output column before any write', async () => {
198+
mockCheckAccess.mockResolvedValue({
199+
ok: true,
200+
table: {
201+
workspaceId: WORKSPACE_ID,
202+
schema: {
203+
columns: [{ id: 'col_a', name: 'amount', type: 'number', workflowGroupId: 'g1' }],
204+
},
205+
},
206+
})
207+
208+
const response = await patch({ type: 'string', required: true })
209+
210+
expect(response.status).toBe(400)
211+
expect(await response.json()).toMatchObject({
212+
error: expect.stringContaining('workflow-output column'),
213+
})
214+
expect(mockUpdateColumnType).not.toHaveBeenCalled()
215+
})
216+
173217
it('rejects unique on a type that cannot carry it without renaming first', async () => {
174218
mockCheckAccess.mockResolvedValue({
175219
ok: true,

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
182182
{ status: 400 }
183183
)
184184
}
185+
if (
186+
currentColumn?.workflowGroupId &&
187+
(updates.required !== undefined || updates.unique !== undefined)
188+
) {
189+
return NextResponse.json(
190+
{
191+
error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`,
192+
},
193+
{ status: 400 }
194+
)
195+
}
185196
if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) {
186197
return NextResponse.json(
187198
{ error: `Cannot set a ${resultingType} column as unique` },
@@ -201,6 +212,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
201212
// Forwarded so the conversion validates against the constraint this
202213
// same request is about to set, not the column's current one.
203214
...(updates.required !== undefined ? { required: updates.required } : {}),
215+
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
204216
...renameWithTypedWrite,
205217
},
206218
requestId

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,17 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
216216
{ status: 400 }
217217
)
218218
}
219+
if (
220+
currentColumn?.workflowGroupId &&
221+
(updates.required !== undefined || updates.unique !== undefined)
222+
) {
223+
return NextResponse.json(
224+
{
225+
error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`,
226+
},
227+
{ status: 400 }
228+
)
229+
}
219230
if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) {
220231
return NextResponse.json(
221232
{ error: `Cannot set a ${resultingType} column as unique` },
@@ -235,6 +246,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
235246
// Forwarded so the conversion validates against the constraint this
236247
// same request is about to set, not the column's current one.
237248
...(updates.required !== undefined ? { required: updates.required } : {}),
249+
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
238250
...renameWithTypedWrite,
239251
},
240252
requestId

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

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

488+
/**
489+
* Whether any two rows share a stored value in this column.
490+
*
491+
* Shared by the constraint write and the retype's pre-validation so the two
492+
* cannot drift — the same reason {@link countEmptyCells} is shared. A retype
493+
* that sets `unique` in the same request has to run this against the values the
494+
* conversion is ABOUT to write, not the ones on disk: coercing `"5"` and `"5.0"`
495+
* to a number manufactures a duplicate that no pre-scan of the raw text sees.
496+
*/
497+
async function hasDuplicateValues(
498+
trx: DbTransaction,
499+
tableId: string,
500+
columnKey: string
501+
): Promise<boolean> {
502+
const duplicates = (await trx.execute(
503+
sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${tableId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1`
504+
)) as { val: string; cnt: number }[]
505+
return duplicates.length > 0
506+
}
507+
488508
/**
489509
* Validates a pending rename against the schema it will land in, and returns
490510
* the renamed column.
@@ -762,6 +782,21 @@ export async function updateColumnType(
762782
await writeBackCoercedCells(trx, data.tableId, columnKey, coercedByRowId)
763783
}
764784

785+
// A `unique` arriving with this retype is validated HERE, against the values
786+
// the conversion just wrote — not by the separate constraint write that
787+
// follows. The conversion itself manufactures duplicates that no scan of the
788+
// pre-conversion data can see (`"5"` and `"5.0"` both coerce to `5`), and
789+
// that write runs in its own transaction, so discovering it there would
790+
// report an error with the retype already committed and the original text
791+
// irrecoverably rewritten.
792+
if (data.unique === true && !column.unique) {
793+
if (await hasDuplicateValues(trx, data.tableId, columnKey)) {
794+
throw new Error(
795+
`Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.`
796+
)
797+
}
798+
}
799+
765800
await trx
766801
.update(userTableDefinitions)
767802
.set({ schema: updatedSchema, updatedAt: now })
@@ -829,11 +864,7 @@ export async function updateColumnConstraints(
829864
}
830865

831866
if (data.unique === true && !column.unique) {
832-
const duplicates = (await trx.execute(
833-
sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${data.tableId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1`
834-
)) as { val: string; cnt: number }[]
835-
836-
if (duplicates.length > 0) {
867+
if (await hasDuplicateValues(trx, data.tableId, columnKey)) {
837868
throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`)
838869
}
839870
}

apps/sim/lib/table/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,12 @@ export interface UpdateColumnTypeData {
793793
multiple?: boolean
794794
/** Currency to set when changing to the `currency` type. */
795795
currencyCode?: string
796+
/**
797+
* The `unique` value the same request is about to set. Validated inside the
798+
* retype against the post-conversion values, because the conversion is what
799+
* can create the duplicates.
800+
*/
801+
unique?: boolean
796802
/**
797803
* The `required` value the same request is about to set, when it changes type
798804
* and constraints together. Those are separate transactions, so the

0 commit comments

Comments
 (0)