Skip to content

Commit 6c5fc2a

Browse files
committed
fix(tables): write back coerced values on every conversion
Round 4 findings, all real. A conversion is allowed exactly when the target type's `coerce` accepts the value — and `coerce` frequently TRANSFORMS it. Only `select` and `currency` wrote the transformed value back, so a conversion to any other transforming type left the cell holding its old bytes under the new type. Converting a number column to `date` accepted epoch values, stored them unchanged, and then `(data->>'col')::timestamptz` failed on EVERY query against that column. I opened this myself by defaulting `isCompatibleWith` to `coerce(...).ok`. Fixed at the class rather than the instance: the compatibility scan now records whatever `coerce` produced whenever it differs from what is stored, and one generic write-back applies it. That subsumes the currency-specific migration entirely, so it and its helpers are gone. `select` keeps its own id↔name migrations, which are not coerce-expressible in the outbound direction. The post-conversion column definition is built once, before the scan, so the coercion reads the same metadata the stored value is later validated against. Exponent parsing was ambiguous when followed by text: `1e5 EUR` read as 15. An `e` with a digit on both sides is an exponent marker, so if the string is not a clean numeric literal it is refused rather than guessed — the digit on both sides is what keeps the `E` inside `12 EUR` parsing normally. A failed rename could still leave a typed change committed. The one rename failure a caller can cause — a name already taken — is now rejected up front, leaving only the concurrent-collision race, which no pre-flight check can close without spanning all writes in one transaction.
1 parent 3fc73a3 commit 6c5fc2a

7 files changed

Lines changed: 213 additions & 86 deletions

File tree

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,31 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
133133
expect(mockRenameColumn).not.toHaveBeenCalled()
134134
})
135135

136+
it('rejects a name already taken before any write runs', async () => {
137+
mockCheckAccess.mockResolvedValue({
138+
ok: true,
139+
table: {
140+
workspaceId: WORKSPACE_ID,
141+
schema: {
142+
columns: [
143+
{ id: 'col_a', name: 'amount', type: 'currency' },
144+
{ id: 'col_b', name: 'taken', type: 'string' },
145+
],
146+
},
147+
},
148+
})
149+
150+
const response = await patch({ name: 'taken', currencyCode: 'EUR' })
151+
152+
expect(response.status).toBe(400)
153+
expect(await response.json()).toMatchObject({
154+
error: expect.stringContaining('already exists'),
155+
})
156+
// The typed write would otherwise have committed under a rename that fails.
157+
expect(mockUpdateColumnCurrency).not.toHaveBeenCalled()
158+
expect(mockRenameColumn).not.toHaveBeenCalled()
159+
})
160+
136161
it('rejects unique on a type that cannot carry it without renaming first', async () => {
137162
mockCheckAccess.mockResolvedValue({
138163
ok: true,

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
157157
)
158158
}
159159
}
160+
// The rename runs last (see below), so a name already taken would fail after
161+
// the typed write committed. This is the only rename failure a caller can
162+
// cause; catching it here leaves just the concurrent-collision race, which
163+
// no pre-flight check can close.
164+
if (
165+
updates.name &&
166+
table.schema.columns.some(
167+
(c) =>
168+
c.name.toLowerCase() === updates.name?.toLowerCase() &&
169+
!columnMatchesRef(c, validated.columnName)
170+
)
171+
) {
172+
return NextResponse.json(
173+
{ error: `Column "${updates.name}" already exists` },
174+
{ status: 400 }
175+
)
176+
}
160177
if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) {
161178
return NextResponse.json(
162179
{ error: `Cannot set a ${resultingType} column as unique` },

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,23 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
191191
)
192192
}
193193
}
194+
// The rename runs last (see below), so a name already taken would fail after
195+
// the typed write committed. This is the only rename failure a caller can
196+
// cause; catching it here leaves just the concurrent-collision race, which
197+
// no pre-flight check can close.
198+
if (
199+
updates.name &&
200+
table.schema.columns.some(
201+
(c) =>
202+
c.name.toLowerCase() === updates.name?.toLowerCase() &&
203+
!columnMatchesRef(c, validated.columnName)
204+
)
205+
) {
206+
return NextResponse.json(
207+
{ error: `Column "${updates.name}" already exists` },
208+
{ status: 400 }
209+
)
210+
}
194211
if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) {
195212
return NextResponse.json(
196213
{ error: `Cannot set a ${resultingType} column as unique` },

apps/sim/lib/table/__tests__/column-type-registry.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* here.
1111
*/
1212
import { describe, expect, it } from 'vitest'
13+
import type { ColumnType } from '@/lib/table/column-types'
1314
import {
1415
ALL_COLUMN_TYPES,
1516
COLUMN_TYPE_REGISTRY,
@@ -75,6 +76,36 @@ describe('registry shape', () => {
7576
})
7677
})
7778

79+
describe('conversion write-back', () => {
80+
// A retype is allowed exactly when the target's `coerce` accepts the value,
81+
// and `coerce` often TRANSFORMS it. The conversion must therefore write the
82+
// transformed value back — filters and sorts apply `jsonbCast` to whatever is
83+
// stored, so a value left in its old shape breaks every query on the column.
84+
it.each`
85+
type | stored | expected
86+
${'date'} | ${1700000000000} | ${'2023-11-14T22:13:20.000Z'}
87+
${'date'} | ${'2024-01-01'} | ${'2024-01-01'}
88+
${'currency'} | ${'$1,234.56'} | ${1234.56}
89+
${'currency'} | ${'1.234,56'} | ${1234.56}
90+
${'number'} | ${'1999'} | ${1999}
91+
`('$type coerces $stored to a value its jsonbCast can read', ({ type, stored, expected }) => {
92+
const column = { name: 'c', type } as ColumnDefinition
93+
const result = COLUMN_TYPE_REGISTRY[type as ColumnType].coerce(stored, column)
94+
expect(result.ok && result.value).toEqual(expected)
95+
})
96+
97+
it('never leaves a numeric-cast type holding something Postgres cannot cast', () => {
98+
// The concrete failure this guards: an epoch number left in a `date`
99+
// column makes `(data->>'col')::timestamptz` throw on every query.
100+
for (const definition of ALL_COLUMN_TYPES) {
101+
if (definition.jsonbCast !== 'timestamptz') continue
102+
const coerced = definition.coerce(1700000000000, { name: 'c', type: definition.id })
103+
expect(coerced.ok).toBe(true)
104+
expect(typeof (coerced as { value: unknown }).value).toBe('string')
105+
}
106+
})
107+
})
108+
78109
describe('metadata ownership', () => {
79110
const column = (over: Partial<ColumnDefinition>): ColumnDefinition =>
80111
({ name: 'c', type: 'string', ...over }) as ColumnDefinition

apps/sim/lib/table/column-types/registry.server.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,34 +62,35 @@ async function migrateSelectCellsToNames(
6262
}
6363

6464
/** Rows rewritten per statement, bounding the size of the jsonb map parameter. */
65-
const CURRENCY_MIGRATION_BATCH_SIZE = 5000
65+
const COERCED_WRITE_BACK_BATCH_SIZE = 5000
6666

6767
/**
68-
* Rewrites a column's cells into the canonical `currency` storage shape: a bare
69-
* JSON number.
68+
* Writes back the values a conversion's coercion produced.
7069
*
71-
* Load-bearing, not cosmetic. Filters and sorts cast the stored text to
72-
* `numeric`, so a `$1,234.56` left behind by a text → currency conversion would
73-
* make **every** query against the column fail — not just render oddly.
70+
* A retype is allowed exactly when the target type's `coerce` accepts the
71+
* value, and `coerce` frequently *transforms* it — an epoch number becomes an
72+
* ISO date, `$1,234.56` becomes `1234.56`. Without this, the cell keeps its old
73+
* bytes under the new type, and since filters and sorts apply the type's
74+
* `jsonbCast` to whatever is stored, an epoch left in a `date` column makes
75+
* `::timestamptz` fail on EVERY query against that column.
7476
*
75-
* Unlike the `select` migrations this cannot be expressed set-based:
76-
* `parseCurrencyInput` disambiguates grouping from decimal separators in JS, and
77-
* a naive SQL strip would turn `1.234.567` into an invalid numeric and abort the
78-
* conversion. The already-parsed amounts are folded into a jsonb map instead, so
79-
* it is still one statement per batch rather than one per row.
77+
* The values arrive already computed (the compatibility scan derived them), so
78+
* this is purely the write. It cannot be expressed set-based — the coercions
79+
* are JS, and a naive SQL equivalent would mangle exactly the inputs they
80+
* disambiguate — so the map is folded into one statement per batch.
8081
*/
81-
async function migrateCellsToCurrencyNumbers(
82+
export async function writeBackCoercedCells(
8283
trx: DbTransaction,
8384
tableId: string,
8485
columnKey: string,
85-
amountByRowId: ReadonlyMap<string, JsonValue>
86+
valueByRowId: ReadonlyMap<string, JsonValue>
8687
): Promise<void> {
87-
if (amountByRowId.size === 0) return
88+
if (valueByRowId.size === 0) return
8889

89-
const entries = [...amountByRowId]
90-
for (let start = 0; start < entries.length; start += CURRENCY_MIGRATION_BATCH_SIZE) {
90+
const entries = [...valueByRowId]
91+
for (let start = 0; start < entries.length; start += COERCED_WRITE_BACK_BATCH_SIZE) {
9192
const batch = JSON.stringify(
92-
Object.fromEntries(entries.slice(start, start + CURRENCY_MIGRATION_BATCH_SIZE))
93+
Object.fromEntries(entries.slice(start, start + COERCED_WRITE_BACK_BATCH_SIZE))
9394
)
9495
await trx.execute(
9596
sql`UPDATE ${userTableRows} AS r
@@ -216,11 +217,7 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record<ColumnType, ColumnTypeServerEnt
216217
migrateCellsFrom: ({ trx, tableId, columnKey, previous }) =>
217218
migrateSelectCellsToNames(trx, tableId, columnKey, previous.options ?? []),
218219
},
219-
currency: {
220-
...COLUMN_TYPE_REGISTRY.currency,
221-
migrateCellsTo: ({ trx, tableId, columnKey, resolved }) =>
222-
migrateCellsToCurrencyNumbers(trx, tableId, columnKey, resolved),
223-
},
220+
currency: COLUMN_TYPE_REGISTRY.currency,
224221
}
225222

226223
/** The inbound migration for a target type, if it has one. */

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

Lines changed: 86 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ import { createLogger } from '@sim/logger'
1515
import { and, count, eq, sql } from 'drizzle-orm'
1616
import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys'
1717
import { columnTypeById, isValueCompatible } from '@/lib/table/column-types'
18-
import { migrationFrom, migrationTo } from '@/lib/table/column-types/registry.server'
18+
import {
19+
migrationFrom,
20+
migrationTo,
21+
writeBackCoercedCells,
22+
} from '@/lib/table/column-types/registry.server'
1923
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
20-
import { parseCurrencyInput, resolveCurrencyCode } from '@/lib/table/currency'
24+
import { resolveCurrencyCode } from '@/lib/table/currency'
2125
import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks'
2226
import type { DbTransaction } from '@/lib/table/planner'
2327
import { stripGroupExecutions } from '@/lib/table/rows/executions'
@@ -476,6 +480,45 @@ export async function deleteColumns(
476480
return def
477481
}
478482

483+
/**
484+
* The column definition a retype produces: prior per-type metadata dropped,
485+
* then only what the TARGET type declares it owns carried forward, then that
486+
* type's own defaults stamped on.
487+
*/
488+
function buildConvertedColumn(
489+
column: ColumnDefinition,
490+
data: UpdateColumnTypeData,
491+
{ isSelectType, targetMultiple }: { isSelectType: boolean; targetMultiple: boolean }
492+
): ColumnDefinition {
493+
const { options: _options, multiple: _multiple, currencyCode: _currencyCode, ...rest } = column
494+
495+
if (isSelectType) {
496+
return {
497+
...rest,
498+
type: data.newType,
499+
options: data.options ?? column.options,
500+
...(targetMultiple ? { multiple: true } : {}),
501+
// Select columns carry no unique constraint: it would compare the stored
502+
// option id, capping each option at one row table-wide, and the UI hides
503+
// the toggle so it could never be cleared again. Dropped here rather than
504+
// in each caller — the sidebar was the only one clearing it, leaving the
505+
// v1 and agent paths to strand it.
506+
unique: false,
507+
}
508+
}
509+
510+
const definition = columnTypeById(data.newType)
511+
const owned = new Set<string>(definition.ownedMetadata)
512+
const carried: ColumnDefinition = {
513+
...rest,
514+
type: data.newType,
515+
...(owned.has('currencyCode') && (data.currencyCode ?? column.currencyCode) !== undefined
516+
? { currencyCode: data.currencyCode ?? column.currencyCode }
517+
: {}),
518+
}
519+
return { ...carried, ...definition.defaultMetadata?.(carried) }
520+
}
521+
479522
/**
480523
* Changes the type of a column. Validates that existing data is compatible.
481524
*
@@ -535,7 +578,6 @@ export async function updateColumnType(
535578
// Options the column will carry after the change — a `select` value is only
536579
// compatible if it resolves against this set.
537580
const isSelectType = data.newType === 'select'
538-
const isCurrencyType = data.newType === 'currency'
539581
const targetOptions = data.options ?? column.options ?? []
540582
const targetMultiple = data.multiple ?? column.multiple
541583
// Leaving `select` behind: stored cells hold option ids, which mean nothing
@@ -560,27 +602,41 @@ export async function updateColumnType(
560602
}
561603
}
562604

605+
/**
606+
* The column definition the table ends up with. Built before the scan so
607+
* the coercion below reads the same metadata (option set, currency) the
608+
* stored value will be validated against afterwards.
609+
*/
610+
const convertedColumn = buildConvertedColumn(column, data, {
611+
isSelectType,
612+
targetMultiple: !!targetMultiple,
613+
})
614+
563615
let incompatibleCount = 0
564616
let blankCount = 0
565617
/**
566-
* Row id → parsed amount, for a conversion into `currency`. Collected here
567-
* rather than re-derived in the migration so it reads the same `effective`
568-
* value the compatibility check accepted — which for a `select` source is
569-
* the option name, not the stored id.
618+
* Row id → the value the cell must END UP holding.
619+
*
620+
* Collected during the compatibility scan rather than re-derived later, so
621+
* it reads the same `effective` value the check accepted — which for a
622+
* `select` source is the option name, not the stored id.
623+
*
624+
* Load-bearing: a conversion is allowed exactly when the target type's
625+
* `coerce` accepts the value, and `coerce` frequently *transforms* it (an
626+
* epoch number becomes an ISO date, a formatted amount becomes a number).
627+
* Without writing the transformed value back, the cell keeps its old bytes
628+
* under the new type — and since filters and sorts apply the type's
629+
* `jsonbCast` to whatever is stored, an epoch left in a `date` column makes
630+
* `::timestamptz` fail on EVERY query against it.
570631
*/
571-
const currencyAmountByRowId = new Map<string, number>()
632+
const coercedByRowId = new Map<string, JsonValue>()
572633
for (const row of rows) {
573634
const rowData = row.data as RowData
574635
const value = rowData[columnKey]
575636
if (value === null || value === undefined) continue
576637

577638
const effective = convertingAwayFromSelect ? selectValueForConversion(column, value) : value
578639

579-
if (isCurrencyType && typeof effective !== 'number') {
580-
const amount = parseCurrencyInput(effective)
581-
if (amount !== null) currencyAmountByRowId.set(row.id, amount)
582-
}
583-
584640
if (
585641
!isValueCompatibleWithType(
586642
effective,
@@ -592,6 +648,16 @@ export async function updateColumnType(
592648
) {
593649
if (effective === null || effective === '') blankCount++
594650
else incompatibleCount++
651+
continue
652+
}
653+
654+
// `select` keeps its own id↔name migrations; everything else writes back
655+
// whatever `coerce` produced, when that differs from what is stored.
656+
if (!isSelectType && effective !== null) {
657+
const coerced = columnTypeById(data.newType).coerce(effective as JsonValue, convertedColumn)
658+
if (coerced.ok && !Object.is(coerced.value, value)) {
659+
coercedByRowId.set(row.id, coerced.value)
660+
}
595661
}
596662
}
597663

@@ -607,45 +673,7 @@ export async function updateColumnType(
607673
)
608674
}
609675

610-
const updatedColumns = schema.columns.map((c, i) => {
611-
if (i !== columnIndex) return c
612-
const {
613-
options: _prevOptions,
614-
multiple: _prevMultiple,
615-
currencyCode: _prevCurrencyCode,
616-
...rest
617-
} = c
618-
// Carry forward only metadata the TARGET type owns — everything else was
619-
// destructured off above and must not survive the conversion — then let
620-
// the type stamp its own defaults, so a type carrying metadata gets it on
621-
// a conversion and not only on create.
622-
if (!isSelectType) {
623-
const definition = columnTypeById(data.newType)
624-
const owned = new Set<string>(definition.ownedMetadata)
625-
const converted: ColumnDefinition = {
626-
...rest,
627-
type: data.newType,
628-
...(owned.has('currencyCode') && (data.currencyCode ?? c.currencyCode) !== undefined
629-
? { currencyCode: data.currencyCode ?? c.currencyCode }
630-
: {}),
631-
}
632-
return { ...converted, ...definition.defaultMetadata?.(converted) }
633-
}
634-
return isSelectType
635-
? {
636-
...rest,
637-
type: data.newType,
638-
options: data.options ?? c.options,
639-
...(targetMultiple ? { multiple: true } : {}),
640-
// Select columns carry no unique constraint: it would compare the
641-
// stored option id, capping each option at one row table-wide, and
642-
// the UI hides the toggle so it could never be cleared again. Drop
643-
// it here rather than in each caller — the sidebar was the only one
644-
// clearing it, leaving the v1 and agent paths to strand it.
645-
unique: false,
646-
}
647-
: { ...rest, type: data.newType }
648-
})
676+
const updatedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c))
649677

650678
const columnValidation = validateColumnDefinition(updatedColumns[columnIndex])
651679
if (!columnValidation.valid) {
@@ -664,10 +692,14 @@ export async function updateColumnType(
664692
columnKey,
665693
previous: column,
666694
target: updatedColumns[columnIndex],
667-
resolved: currencyAmountByRowId,
695+
resolved: coercedByRowId,
668696
}
669697
await migrationFrom(column.type)?.(migrationContext)
670-
await migrationTo(data.newType)?.(migrationContext)
698+
if (isSelectType) {
699+
await migrationTo(data.newType)?.(migrationContext)
700+
} else {
701+
await writeBackCoercedCells(trx, data.tableId, columnKey, coercedByRowId)
702+
}
671703

672704
await trx
673705
.update(userTableDefinitions)

0 commit comments

Comments
 (0)