Skip to content

Commit e79bf81

Browse files
authored
fix(tables): refuse scale suffixes and resolve the lone-separator ambiguity (#6111)
* fix(tables): refuse scale suffixes and resolve the lone-separator ambiguity A verification pass over the previous commit found that my own fix opened a new hole of the class it closed. Widening the currency marker to 1-3 letters made scale suffixes parse: `1.2 M` read as 1.2, so a column of `1.2 M` / `3.4 M` — an ordinary spreadsheet paste — converted cleanly and rewrote every cell a millionfold too small. Before the widening those were rejected and the data was safe. Now refused explicitly, while `kr` and `zł` still parse despite starting with the same letters. Stripping the marker also newly routed formatted zero-decimal amounts into the lone-separator branch, where a single dot was always decimal: `1.235 ¥` read as 1.235 rather than 1235. That is a wrong number where there used to be a refusal, which is the worse failure. A lone separator followed by three digits is now resolved by two signals — a marker means a formatter produced it, and formatters group; a currency carrying three decimals (KWD, TND) reads them as decimals. `coerce` passes the column's code, so the parser can ask. Bare typed input keeps the decimal reading. Also: the unchanged-type throw fell through to a 500 for every type except currency, whose message happened to contain the word; it now maps to 400. And the last three comments describing the removed two-transaction architecture are gone — the previous commit claimed they were and two survived. Documented, and failing closed rather than guessing: markers written flush against the digits (`Rp12,00`) stay rejected, because a letter touching a digit is the only thing distinguishing a currency marker from a part number, and reading `SKU400` as 400 invents a value where refusing merely inconveniences. * fix(tables): fit the whole column-type list in the New column dropdown Adding currency made the list nine items (295px with its separator and padding), past the shared dropdown's 240px cap — so Select and Workflow sat below a scrollbar. Sized on this dropdown rather than on the shared `DropdownMenuContent`, which every other menu in the app relies on. Also gave the sidebar's type combobox headroom, since it gained the same entry and was within 4px of its own cap. * fix(tables): read a lone separator against the column's currency Review of the previous commit found the marker signal alone was too coarse. The evidence: a currency with decimal places ALWAYS formats with both separators — `1.234,56 €`, `$1,234.56`, `1.234,000 KWD`. Only a zero-decimal currency emits a lone one (`1.235 ¥`). So a marker does not imply grouping; the currency's own decimal places decide, and a three-decimal currency's trailing three digits are decimals however the value arrived. That matters most for CSV import, which passed no currency at all: a KWD column importing `0,500` read as five hundred rather than a half. The column's code is now forwarded, since nothing in a CSV carries a marker to fall back on. A bare typed `1.234` still reads as decimals. * fix(tables): let the separator decide a lone-separator amount, not the marker Greptile blocked on `$1.234` reading as 1234, and offered the alternative of stating the input contract explicitly. Looking for a precedent settled it against me: a spreadsheet with a USD-formatted cell stores a typed `1.234` as 1.234 and displays $1.23. Reinterpreting it as one thousand two hundred thirty-four is a thousandfold surprise, and my defense of it was wrong. The rule now keys on the separator, tempered by what the currency can express: - A dot is the decimal point in the notation most people type, so it stays a decimal — except for a currency with no decimal places, where `1.235 ¥` cannot be a fraction of a yen and is the one lone-separator form a formatter actually emits. - A comma is grouping by convention (`1,500` is fifteen hundred), except for a three-decimal currency, where `0,500` is a half. A currency with one or two decimal places always formats with both separators, so no formatter output is decided here at all. This also drops the `hadMarker` signal entirely — once the separator and the currency answer the question, where the string came from stops mattering. * fix(tables): check conversions against the target column, not a rebuilt stub Bugbot flagged that the conversion gate calls `isValueCompatibleWithType`, which hand-builds a column from loose arguments and never sets `currencyCode` — while the coercion a few lines later reads the real one. The reported impact does not occur. Whether an amount parses does not depend on the currency, only which number it yields, so the gate and the coercion always agree on accept/reject; and the value written back is already coerced against the real column, so no cell is stored wrong. I checked this rather than argued it: across nine currency codes and every separator shape in the parser's repertoire, acceptance never diverged. The stub is still worth removing. It is correct only because of an invariant nobody wrote down, and `convertedColumn` — sitting right there, whose own comment already says it exists so the scan reads the same option set and currency — is what the gate should have used all along. So the gate now takes the target column itself. The loose-argument form moves into the test file as local marshalling, leaving production with only the whole-column shape, which cannot silently drop a key a future type adds. No behavior change, so no test changes: a currency-divergence regression test could not fail today, and pinning an invariant this removes the need for would only block a legitimate future change. * fix(tables): vet a currency marker against an allowlist, not a scale denylist Bugbot found that `SCALE_SUFFIX` misses `mio`, `mrd`, `bio`, `tsd`, `mln` and `md`. Each is one to three letters, so each matched the currency-marker pattern and was stripped: `1,2 mio` read as 1.2, a millionfold too small, which is the same failure `1.2 M` had. Adding the six words would leave the next six. The denylist is the defect — it has to enumerate every magnitude abbreviation in every language, and each one it misses silently rescales a value rather than refusing it. So the check is inverted. A bare letter token is stripped only when it names a currency: an ISO 4217 code the runtime can enumerate, or one of the common non-ISO markers people type (`kr`, `zł`, `Kč`). Anything else is left in place, fails the amount-shape check, and is refused. `12 units` and `12 pcs` now fall out of the same rule the magnitude words do, without naming either. A marker carrying a currency symbol skips the check — no magnitude abbreviation contains one — so `R$`, `CHF`, `1 234,56 kr` are untouched. This rests on ISO codes and magnitude words being disjoint, which is true for all 162 codes the runtime knows, and is now pinned by a test so a future collision fails loudly instead of silently. `SCALE_SUFFIX` survives only for a runtime without `Intl.supportedValuesOf`, which cannot enumerate codes and so keeps the old permissive behaviour; rejecting every letter marker there would break `USD 12.50`. It no longer has to be complete, since every modern runtime rejects by non-recognition. Verified the new tests fail when the vetting is reverted. * fix(tables): group a lone dot only when a zero-decimal amount came formatted Greptile found that a bare `1.235` typed into a JPY column stored 1235. The zero-decimal branch read the dot as grouping without asking where the value came from, so typing, pasting, a tool write, or an import all inflated a thousandfold in silence. The rule was inverted relative to its own evidence. Grouping was justified as "the one lone-separator form a formatter emits" — and a formatter always emits its marker too. The marker is exactly what separates formatter output from someone typing, and I had removed it as redundant two commits ago. So a lone dot groups only for a zero-decimal currency AND only when a marker came with it. `1.235 ¥` and `JPY 1.235` still read as 1235; a bare `1.235` now stores 1.235 and displays `¥1` — wrong in a way the writer can see and correct, rather than a silent thousandfold error. This does not walk back the earlier fix. For a currency with one or two decimal places a formatter always emits BOTH separators, so a lone dot there never came from one and stays a decimal whether or not a marker is present — `$1.234` is still 1.234. The marker only carries information in the zero-decimal case, which is the only case that now consults it. The two overlapping lone-separator tests are replaced by three that each state one rule. Verified the new one fails when the marker requirement is reverted.
1 parent ef00750 commit e79bf81

11 files changed

Lines changed: 297 additions & 67 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
323323
msg.includes('incompatible') ||
324324
msg.includes('duplicate') ||
325325
msg.includes('option') ||
326-
msg.includes('currency')
326+
msg.includes('currency') ||
327+
msg.includes('is already type')
327328
) {
328329
return NextResponse.json({ error: msg }, { status: 400 })
329330
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
368368
msg.includes('incompatible') ||
369369
msg.includes('duplicate') ||
370370
msg.includes('option') ||
371-
msg.includes('currency')
371+
msg.includes('currency') ||
372+
msg.includes('is already type')
372373
) {
373374
return NextResponse.json({ error: msg }, { status: 400 })
374375
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ function ColumnConfigBody({
282282
value={typeInput}
283283
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
284284
placeholder='Select type'
285-
maxHeight={260}
285+
maxHeight={300}
286286
/>
287287
</div>
288288
</>

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ export function NewColumnDropdown({
8686
const menu = (
8787
<DropdownMenu>
8888
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
89-
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
89+
{/* Taller than the 240px shared default: the full type list is 9 items
90+
(295px with its separator and padding), so the default cut the last
91+
two off behind a scrollbar. Sized here rather than in the shared
92+
component, which every other dropdown in the app relies on. */}
93+
<DropdownMenuContent align='start' side='bottom' sideOffset={4} className='max-h-[320px]'>
9094
<>
9195
<DropdownMenuItem onSelect={onPickEnrichment}>
9296
<Sparkles className='size-[14px] text-[var(--text-icon)]' />

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,32 @@ import { describe, expect, it } from 'vitest'
88
import { COLUMN_TYPE_REGISTRY } from '@/lib/table/column-types'
99
import {
1010
applyPendingRename,
11-
isValueCompatibleWithType,
11+
isValueCompatibleWithColumn,
1212
selectValueForConversion,
1313
} from '@/lib/table/columns/service'
1414
import { resolveSelectOptionId } from '@/lib/table/select-options'
15-
import type { ColumnDefinition, SelectOption } from '@/lib/table/types'
15+
import type { ColumnDefinition, ColumnType, SelectOption } from '@/lib/table/types'
16+
17+
/**
18+
* Marshals the loose per-type arguments these cases are written in into the
19+
* target column the gate takes. Lives here rather than in the service so
20+
* production has only the whole-column form, which cannot drop a metadata key.
21+
*/
22+
function isValueCompatibleWithType(
23+
value: unknown,
24+
targetType: ColumnType,
25+
targetOptions: SelectOption[] = [],
26+
targetMultiple = false,
27+
targetRequired = false
28+
): boolean {
29+
return isValueCompatibleWithColumn(value, {
30+
name: '',
31+
type: targetType,
32+
options: targetOptions,
33+
multiple: targetMultiple,
34+
required: targetRequired,
35+
})
36+
}
1637

1738
const OPTIONS: SelectOption[] = [
1839
{ id: 'opt_a', name: 'Alpha' },

apps/sim/lib/table/__tests__/currency.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,79 @@ describe('parseCurrencyInput', () => {
160160
expect(parseCurrencyInput('CHF 1’234.56')).toBe(1234.56)
161161
})
162162

163+
it('reads a lone dot as the decimal point people type', () => {
164+
// Typing `1.234` in a USD cell means 1.234, which the display rounds to
165+
// $1.23 exactly as a spreadsheet does. Reading it as 1,234 would be a
166+
// thousandfold surprise, marker or no marker.
167+
expect(parseCurrencyInput('$1.234', 'USD')).toBe(1.234)
168+
expect(parseCurrencyInput('1.234')).toBe(1.234)
169+
expect(parseCurrencyInput('1.234', 'EUR')).toBe(1.234)
170+
})
171+
172+
it('groups a lone dot only for a zero-decimal currency that came formatted', () => {
173+
// `1.235 ¥` cannot be a fraction of a yen, and is the single lone-separator
174+
// form a formatter emits — but a formatter always emits its marker too.
175+
expect(parseCurrencyInput('1.235 ¥', 'JPY')).toBe(1235)
176+
expect(parseCurrencyInput('JPY 1.235', 'JPY')).toBe(1235)
177+
// Bare, it is someone typing. Inflating that to 1235 would be a silent
178+
// thousandfold error; read literally it stores 1.235 and displays ¥1 —
179+
// wrong in a way the writer can see and fix.
180+
expect(parseCurrencyInput('1.235', 'JPY')).toBe(1.235)
181+
expect(parseCurrencyInput('1.235', 'CLP')).toBe(1.235)
182+
})
183+
184+
it('reads a lone comma as grouping, except where the currency has three decimals', () => {
185+
// `1,500` is fifteen hundred by convention. A three-decimal currency's
186+
// trailing three digits are decimals however the value arrived — which
187+
// matters most for CSV import, where nothing carries a marker.
188+
expect(parseCurrencyInput('1,500', 'USD')).toBe(1500)
189+
expect(parseCurrencyInput('1,500')).toBe(1500)
190+
expect(parseCurrencyInput('0,500', 'KWD')).toBe(0.5)
191+
expect(parseCurrencyInput('0,500 KWD', 'KWD')).toBe(0.5)
192+
expect(parseCurrencyInput('12,000', 'TND')).toBe(12)
193+
expect(parseCurrencyInput('12,000 TND', 'TND')).toBe(12)
194+
})
195+
196+
it('refuses a scale suffix rather than shrinking the value', () => {
197+
// `1.2 M` read as 1.2 would rewrite a column of millions a millionfold too
198+
// small — the same invented-value failure as an identifier, inverted.
199+
expect(parseCurrencyInput('1.2 M')).toBeNull()
200+
expect(parseCurrencyInput('5 K')).toBeNull()
201+
expect(parseCurrencyInput('3.4 bn')).toBeNull()
202+
expect(parseCurrencyInput('10 B')).toBeNull()
203+
// `kr` is a currency marker, not a scale suffix, despite starting with k.
204+
expect(parseCurrencyInput('1 234,56 kr')).toBe(1234.56)
205+
})
206+
207+
it('refuses non-English magnitude words too', () => {
208+
// These are why the marker check is an allowlist. As a denylist each one
209+
// had to be named, and any that was missed read as a bare number — `1,2
210+
// mio` as 1.2 rather than 1.2 million.
211+
expect(parseCurrencyInput('1,2 mio')).toBeNull()
212+
expect(parseCurrencyInput('3,4 mrd')).toBeNull()
213+
expect(parseCurrencyInput('5 tsd')).toBeNull()
214+
expect(parseCurrencyInput('2,5 mln')).toBeNull()
215+
expect(parseCurrencyInput('1,5 bio')).toBeNull()
216+
expect(parseCurrencyInput('7 md')).toBeNull()
217+
// Anything else beside a number is refused by the same rule, so the parser
218+
// no longer has to enumerate what a magnitude word looks like.
219+
expect(parseCurrencyInput('12 units')).toBeNull()
220+
expect(parseCurrencyInput('12 pcs')).toBeNull()
221+
})
222+
223+
it('keeps every ISO code strippable — no code is a magnitude word', () => {
224+
// The allowlist is only safe because these two sets do not overlap. If a
225+
// future ISO code ever collided with a magnitude abbreviation, this fails
226+
// and the marker rule needs a tiebreak.
227+
const codes = new Set(
228+
(Intl as unknown as { supportedValuesOf: (k: string) => string[] }).supportedValuesOf(
229+
'currency'
230+
)
231+
)
232+
const magnitudeWords = ['K', 'M', 'B', 'T', 'BN', 'MN', 'MIO', 'MRD', 'TSD', 'MLN', 'BIO', 'MD']
233+
expect(magnitudeWords.filter((w) => codes.has(w))).toEqual([])
234+
})
235+
163236
it('rejects an identifier whose letters touch its digits', () => {
164237
// The distinguishing rule: a currency marker is always separated from the
165238
// number by a space or a symbol, so letters touching digits mean this is a

apps/sim/lib/table/column-types/currency.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ export const currencyColumnType: ColumnTypeDefinition = {
2727
typeaheadPattern: /[\d.,\-\p{Sc}]/u,
2828
parseErrorMessage: 'Invalid amount',
2929

30-
coerce(value) {
30+
coerce(value, column) {
3131
// Stored as a bare number, but accepts the formatted shapes an amount
3232
// arrives in — `$1,234.56`, `1 234,56 €`, `(12.00)` — so a paste, CSV
3333
// import, or tool write lands as a number rather than being nulled.
34-
const parsed = parseCurrencyInput(value)
34+
const parsed = parseCurrencyInput(value, column.currencyCode)
3535
return parsed === null ? { ok: false } : { ok: true, value: parsed }
3636
},
3737

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

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -749,15 +749,14 @@ export async function updateColumnType(
749749
// once the column is text/number/etc. Check compatibility against the option
750750
// NAME — that's what the cell will actually become (migrated below).
751751
const convertingAwayFromSelect = column.type === 'select' && !isSelectType
752-
// The constraint the column ends up with, which may be arriving in this same
753-
// request. `updateColumnConstraints` runs as its own transaction afterwards,
754-
// so validating against the column's CURRENT flag would let the conversion
755-
// commit and only then fail the constraint.
752+
// The constraint the column ends up with, which may be arriving in this
753+
// same request — this write applies it, so the scan below has to judge
754+
// against the target value rather than the current one.
756755
const targetRequired = !!(data.required ?? column.required)
757756

758757
// Rows missing the key (or holding null/`[]`) are filtered out of `rows`
759758
// entirely, so the loop below can never see them — they have to be counted
760-
// separately, through the same predicate the constraint write will use.
759+
// separately, through the same predicate `applyConstraints` uses.
761760
if (targetRequired) {
762761
const emptyCount = await countEmptyCells(trx, data.tableId, columnKey)
763762
if (emptyCount > 0) {
@@ -802,15 +801,7 @@ export async function updateColumnType(
802801

803802
const effective = convertingAwayFromSelect ? selectValueForConversion(column, value) : value
804803

805-
if (
806-
!isValueCompatibleWithType(
807-
effective,
808-
data.newType,
809-
targetOptions,
810-
!!targetMultiple,
811-
targetRequired
812-
)
813-
) {
804+
if (!isValueCompatibleWithColumn(effective, convertedColumn)) {
814805
// A cell the target cannot read but that is merely EMPTY is not a
815806
// conversion failure — the write path already turns an unreadable value
816807
// into null on an optional column, so the conversion does the same. Only
@@ -1331,29 +1322,24 @@ export function selectValueForConversion(column: ColumnDefinition, value: unknow
13311322
}
13321323

13331324
/**
1334-
* Checks if a value is compatible with a target column type. For `select`,
1335-
* `targetOptions` is the option set the column will carry after the change: a
1336-
* value is compatible only if every part of it resolves to one of those options
1337-
* (by id or name). This blocks a conversion that would otherwise strand or
1338-
* silently drop values on the next row write.
1325+
* Checks a value against the column definition the table will end up with.
1326+
*
1327+
* Takes the whole target column rather than loose per-type arguments: the gate
1328+
* has to read the SAME metadata the later coercion does, and a hand-built stub
1329+
* silently omits whatever key its author did not think of. Today that key would
1330+
* be `currencyCode` — a three-decimal column reads `0,500` as a half dinar
1331+
* while a stub without the code reads it as five hundred.
1332+
*
1333+
* That divergence is currently invisible, because whether an amount parses at
1334+
* all does not depend on the currency, only which number it yields — and the
1335+
* write-back already coerces against the real column. Passing the real column
1336+
* here means it cannot become visible when that stops being true.
13391337
*
13401338
* Callers converting *away* from `select` must pass the resolved option
13411339
* name(s), not the stored ids — see {@link selectValueForConversion}.
13421340
*/
1343-
export function isValueCompatibleWithType(
1344-
value: unknown,
1345-
targetType: (typeof COLUMN_TYPES)[number],
1346-
targetOptions: SelectOption[] = [],
1347-
targetMultiple = false,
1348-
targetRequired = false
1349-
): boolean {
1341+
export function isValueCompatibleWithColumn(value: unknown, target: ColumnDefinition): boolean {
13501342
if (value === null || value === undefined) return true
13511343
// Each type reads only the metadata it owns.
1352-
return isValueCompatible(value, {
1353-
name: '',
1354-
type: targetType,
1355-
options: targetOptions,
1356-
multiple: targetMultiple,
1357-
required: targetRequired,
1358-
})
1344+
return isValueCompatible(value, target)
13591345
}

0 commit comments

Comments
 (0)