Skip to content

Commit a06b2f2

Browse files
committed
fix(tables): final-audit findings
Backs out CSV email/phone inference. Inference reads a 100-row sample but the write path coerces every row and NULLS what the column's type rejects, so a contact export whose first 100 rows are clean and whose row 250 holds `n/a` had that cell silently destroyed — where inferring `string` imported it verbatim. The same hazard already exists for number/boolean/date and is accepted there; it is materially likelier on contact data, which is exactly where these types get used. Importing into a column the user typed themselves is unaffected, and the reasoning is recorded so this is not re-added as an "improvement". Clearing `includeTime` ran the irreversible truncation. The migration fired whenever `target.includeTime` was falsy, and a cleared key is absent — the tri-state's "legacy column, holds instants", the one state that must never be truncated. It now requires an explicit transition to `false`. Reachable through the copilot tool, which builds its patch from a loose arg bag. A fractional "Decimal places" silently saved 0: `2.5` is finite, so the `Number.isFinite` guard passed it to `clampPrecision`, which falls back to 0 for a non-integer — the exact outcome the comment above it promised to prevent. Phone substring filters compared a formatted fragment against punctuation- stripped storage and matched nothing. Fragments are not whole values, so `coerce` cannot validate them; types now declare `normalizeFilterFragment`. A fragment with no digits is left alone — reducing it to `''` would turn `ILIKE '%%'` into a match on every row. Also: the retype's select branch was still two-state, so an option clear fell back to the pre-conversion value; `onSettled`'s column lookup was case-sensitive where `onMutate`'s is not, skipping row invalidation after a truncation; `percent` rendered a non-numeric cell as blank, hiding its contents; `boolean` and `select` declared `canonicalizesValues: false` despite both transforming, and `select` declared itself orderable. Renames `TableColumnError` to `TableRequestError` — `withLockedTable` and `restoreTable` raise it too — and finishes the migration in the DELETE handlers and v1 POST, which still carried substring lists. Those covered today's messages but would have silently 500'd the next typed error added. The `includeTime` toggle now warns before a save that would drop stored times; the comment claiming it already did was false. Corrects two other stale docs: the unknown-column range fallback is text, not numeric, and `precision` IS lossy in CSV export even though storage keeps the full value.
1 parent 7496079 commit a06b2f2

20 files changed

Lines changed: 284 additions & 159 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { hybridAuthMockFns } from '@sim/testing'
1212
import { getErrorMessage } from '@sim/utils/errors'
1313
import { NextRequest } from 'next/server'
1414
import { beforeEach, describe, expect, it, vi } from 'vitest'
15-
import { TableColumnError } from '@/lib/table/errors'
15+
import { TableRequestError } from '@/lib/table/errors'
1616

1717
const {
1818
mockCheckAccess,
@@ -162,11 +162,11 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
162162
})
163163
// Stands in for the race the guards cannot close: the column stopped being
164164
// a currency between the snapshot the guards read and this write. The
165-
// service raises `TableColumnError` for a caller-fixable failure, which is
165+
// service raises `TableRequestError` for a caller-fixable failure, which is
166166
// what earns the 400 — a plain `Error` now means the server genuinely broke
167167
// and correctly returns 500.
168168
mockUpdateColumnMetadata.mockRejectedValue(
169-
new TableColumnError('Cannot set currency on column "amount" of type "string"')
169+
new TableRequestError('Cannot set currency on column "amount" of type "string"')
170170
)
171171

172172
const response = await patch({ name: 'renamed', currencyCode: 'USD' })

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@ import {
2222
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
2323
import { columnTypeById, metadataKeysIn, pickMetadata } from '@/lib/table/column-types'
2424
import { validateMetadataUpdate } from '@/lib/table/columns/metadata'
25-
import { TableColumnError } from '@/lib/table/errors'
25+
import { TableRequestError } from '@/lib/table/errors'
2626
import { signalTableSchemaChanged } from '@/lib/table/events'
2727
import {
2828
accessError,
2929
checkAccess,
3030
normalizeColumn,
31-
rootErrorMessage,
3231
tableLockErrorResponse,
3332
} from '@/app/api/table/utils'
3433

@@ -81,7 +80,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
8180

8281
// One typed check instead of a per-message substring list: the service
8382
// says whether a failure is the caller's and what status it deserves.
84-
if (error instanceof TableColumnError) {
83+
if (error instanceof TableRequestError) {
8584
return NextResponse.json({ error: error.message }, { status: error.status })
8685
}
8786

@@ -300,7 +299,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
300299

301300
// One typed check instead of a per-message substring list: the service
302301
// says whether a failure is the caller's and what status it deserves.
303-
if (error instanceof TableColumnError) {
302+
if (error instanceof TableRequestError) {
304303
return NextResponse.json({ error: error.message }, { status: error.status })
305304
}
306305

@@ -354,12 +353,10 @@ export const DELETE = withRouteHandler(
354353
return validationErrorResponse(error, 'Invalid request data')
355354
}
356355

357-
const msg = rootErrorMessage(error)
358-
if (msg.includes('not found') || msg === 'Table not found') {
359-
return NextResponse.json({ error: msg }, { status: 404 })
360-
}
361-
if (msg.includes('Cannot delete') || msg.includes('last column')) {
362-
return NextResponse.json({ error: msg }, { status: 400 })
356+
// One typed check instead of a per-message substring list: the service
357+
// says whether a failure is the caller's and what status it deserves.
358+
if (error instanceof TableRequestError) {
359+
return NextResponse.json({ error: error.message }, { status: error.status })
363360
}
364361

365362
logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error)

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,9 @@ describe('POST /api/table/[tableId]/import', () => {
430430
expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
431431
// Inferred as `email`, not `string`: the fixture values are real
432432
// addresses, and CSV inference now recognises them.
433-
expect(appendAdditions()).toEqual([expect.objectContaining({ name: 'email', type: 'email' })])
433+
expect(appendAdditions()).toEqual([
434+
expect.objectContaining({ name: 'email', type: 'string' }),
435+
])
434436
// Existing columns have no id (legacy) → keyed by name; the new `email`
435437
// column was assigned id `col_deadbeefcafef00d` (mocked generateId).
436438
expect(appendRows()).toEqual([
@@ -473,7 +475,7 @@ describe('POST /api/table/[tableId]/import', () => {
473475
)
474476
expect(response.status).toBe(200)
475477
expect(appendAdditions()).toEqual([
476-
expect.objectContaining({ name: 'Email_2', type: 'email' }),
478+
expect.objectContaining({ name: 'Email_2', type: 'string' }),
477479
])
478480
})
479481

@@ -539,7 +541,9 @@ describe('POST /api/table/[tableId]/import', () => {
539541
// Route forwarded the column addition into the (now atomic) import op.
540542
// Inferred as `email`, not `string`: the fixture values are real
541543
// addresses, and CSV inference now recognises them.
542-
expect(appendAdditions()).toEqual([expect.objectContaining({ name: 'email', type: 'email' })])
544+
expect(appendAdditions()).toEqual([
545+
expect.objectContaining({ name: 'email', type: 'string' }),
546+
])
543547
expect(response.status).toBe(400)
544548
const data = await response.json()
545549
expect(data.success).toBeUndefined()

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

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
2222
import { columnTypeById, metadataKeysIn, pickMetadata } from '@/lib/table/column-types'
2323
import { validateMetadataUpdate } from '@/lib/table/columns/metadata'
24-
import { TableColumnError } from '@/lib/table/errors'
24+
import { TableRequestError } from '@/lib/table/errors'
2525
import { signalTableSchemaChanged } from '@/lib/table/events'
2626
import {
2727
accessError,
@@ -104,21 +104,10 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
104104
const validationResponse = v1ValidationErrorResponseFromError(error)
105105
if (validationResponse) return validationResponse
106106

107-
if (error instanceof Error) {
108-
// Same caller-error set the internal columns route maps — an invalid
109-
// select option set is a bad request, not a server fault.
110-
if (
111-
error.message.includes('already exists') ||
112-
error.message.includes('maximum column') ||
113-
error.message.includes('Invalid column') ||
114-
error.message.includes('exceeds maximum') ||
115-
error.message.includes('option')
116-
) {
117-
return NextResponse.json({ error: error.message }, { status: 400 })
118-
}
119-
if (error.message === 'Table not found') {
120-
return NextResponse.json({ error: error.message }, { status: 404 })
121-
}
107+
// One typed check instead of a per-message substring list: the service
108+
// says whether a failure is the caller's and what status it deserves.
109+
if (error instanceof TableRequestError) {
110+
return NextResponse.json({ error: error.message }, { status: error.status })
122111
}
123112

124113
logger.error(`[${requestId}] Error adding column to table:`, error)
@@ -353,7 +342,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
353342

354343
// One typed check instead of a per-message substring list: the service
355344
// says whether a failure is the caller's and what status it deserves.
356-
if (error instanceof TableColumnError) {
345+
if (error instanceof TableRequestError) {
357346
return NextResponse.json({ error: error.message }, { status: error.status })
358347
}
359348

@@ -424,13 +413,10 @@ export const DELETE = withRouteHandler(
424413
const validationResponse = v1ValidationErrorResponseFromError(error)
425414
if (validationResponse) return validationResponse
426415

427-
if (error instanceof Error) {
428-
if (error.message.includes('not found') || error.message === 'Table not found') {
429-
return NextResponse.json({ error: error.message }, { status: 404 })
430-
}
431-
if (error.message.includes('Cannot delete') || error.message.includes('last column')) {
432-
return NextResponse.json({ error: error.message }, { status: 400 })
433-
}
416+
// One typed check instead of a per-message substring list: the service
417+
// says whether a failure is the caller's and what status it deserves.
418+
if (error instanceof TableRequestError) {
419+
return NextResponse.json({ error: error.message }, { status: error.status })
434420
}
435421

436422
logger.error(`[${requestId}] Error deleting column from table:`, error)

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,13 @@ function ColumnConfigBody({
172172
const wantsCurrency = typeOwnsMetadataKey(typeInput, 'currencyCode')
173173
const wantsPrecision = typeOwnsMetadataKey(typeInput, 'precision')
174174
// `undefined` means "no precision declared" — the field is legitimately
175-
// clearable back to rendering values as stored. A non-empty value that is not
176-
// a finite number is treated the same rather than clamping to 0, so garbage
177-
// never silently saves as "zero decimal places".
175+
// clearable back to rendering values as stored. Anything that is not a whole
176+
// number is treated the same rather than clamped, because `clampPrecision`
177+
// falls back to 0 for a non-integer: `2.5` is finite, so a `Number.isFinite`
178+
// guard let it through and silently saved "zero decimal places".
178179
const precisionNumber = Number(precisionInput)
179180
const parsedPrecision =
180-
precisionInput.trim() === '' || !Number.isFinite(precisionNumber)
181+
precisionInput.trim() === '' || !Number.isInteger(precisionNumber)
181182
? undefined
182183
: clampPrecision(precisionNumber)
183184
const wantsIncludeTime = typeOwnsMetadataKey(typeInput, 'includeTime')
@@ -392,13 +393,23 @@ function ColumnConfigBody({
392393
{wantsIncludeTime && (
393394
<>
394395
<FieldDivider />
395-
<div className='flex items-center justify-between pl-0.5'>
396-
<Label htmlFor='column-sidebar-include-time'>Include time</Label>
397-
<Switch
398-
id='column-sidebar-include-time'
399-
checked={includeTimeInput}
400-
onCheckedChange={(v) => setIncludeTimeInput(!!v)}
401-
/>
396+
<div className='flex flex-col gap-[9px]'>
397+
<div className='flex items-center justify-between pl-0.5'>
398+
<Label htmlFor='column-sidebar-include-time'>Include time</Label>
399+
<Switch
400+
id='column-sidebar-include-time'
401+
checked={includeTimeInput}
402+
onCheckedChange={(v) => setIncludeTimeInput(!!v)}
403+
/>
404+
</div>
405+
{/* Turning this off rewrites every cell and cannot be undone by
406+
turning it back on — the times are gone. Shown only when the
407+
save would actually perform that rewrite. */}
408+
{baselineIncludeTime && !includeTimeInput && (
409+
<p className='pl-0.5 text-[var(--text-error)] text-caption'>
410+
Saving will remove the time from every cell in this column. This can’t be undone.
411+
</p>
412+
)}
402413
</div>
403414
</>
404415
)}

apps/sim/hooks/queries/tables.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1407,8 +1407,12 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) {
14071407
// keys do that is declared by the type (`metadataRewritesCells`) rather
14081408
// than listed here, so a future key that rewrites cells invalidates rows
14091409
// without an edit at this call site.
1410+
// Case-insensitive on the NAME, matching `onMutate`'s lookup. A
1411+
// mismatched lookup here silently skips the row invalidation an
1412+
// `includeTime` truncation needs, leaving the grid on pre-migration values.
1413+
const settledLower = variables.columnName.toLowerCase()
14101414
const updatedColumn = context?.previousDetail?.schema.columns.find(
1411-
(c) => getColumnId(c) === variables.columnName || c.name === variables.columnName
1415+
(c) => getColumnId(c) === variables.columnName || c.name.toLowerCase() === settledLower
14121416
)
14131417
const { generic, dedicated } = metadataKeysIn(variables.updates)
14141418
const rewritesRows =

apps/sim/lib/table/__tests__/column-types-contact.test.ts

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import {
1919
ownersOfMetadataKey,
2020
pickMetadata,
2121
} from '@/lib/table/column-types'
22+
import { metadataMigrationFor } from '@/lib/table/column-types/registry.server'
2223
import { buildConvertedColumn } from '@/lib/table/columns/service'
23-
import { coerceValue, inferColumnType } from '@/lib/table/import'
24+
import { coerceValue } from '@/lib/table/import'
2425
import { filterRulesToFilter, prunePredicateForColumns } from '@/lib/table/query-builder/converters'
2526
import type { ColumnDefinition } from '@/lib/table/types'
2627

@@ -426,20 +427,86 @@ describe('follow-up fixes', () => {
426427
expect(neg.ok && neg.value).toBe(-2.5)
427428
}
428429
})
430+
})
429431

430-
it('infers an email column from a CSV, using the type’s own validation', () => {
431-
expect(inferColumnType(['ada@example.com', 'bob@example.co.uk'])).toBe('email')
432-
expect(inferColumnType(['ada@example.com', 'not an address'])).toBe('string')
432+
describe('final-audit regressions', () => {
433+
it('never truncates a legacy date column when includeTime is CLEARED', async () => {
434+
// Absent is the tri-state's "legacy column, holds instants". Gating the
435+
// migration on `target.includeTime` being falsy also fired for a cleared
436+
// key, destroying every stored time of day.
437+
const migrate = metadataMigrationFor('date')
438+
expect(migrate).toBeDefined()
439+
if (!migrate) return
440+
const ran: string[] = []
441+
const trx = {
442+
execute: async () => {
443+
ran.push('rewrote')
444+
},
445+
} as never
446+
447+
const call = (previous: ColumnDefinition, target: ColumnDefinition) =>
448+
migrate({ trx, tableId: 't', columnKey: 'c', previous, target, resolved: new Map() })
449+
450+
// Cleared (absent) on a legacy column: must NOT rewrite.
451+
await call({ name: 'c', type: 'date' }, { name: 'c', type: 'date' })
452+
expect(ran).toHaveLength(0)
453+
// Cleared from an explicit true: must NOT rewrite either.
454+
await call({ name: 'c', type: 'date', includeTime: true }, { name: 'c', type: 'date' })
455+
expect(ran).toHaveLength(0)
456+
// The real transition to date-only DOES rewrite.
457+
await call(
458+
{ name: 'c', type: 'date', includeTime: true },
459+
{ name: 'c', type: 'date', includeTime: false }
460+
)
461+
expect(ran.length).toBeGreaterThan(0)
433462
})
434463

435-
it('infers phone only on strong evidence, never from bare digits or dashes', () => {
436-
expect(inferColumnType(['+1 555 123 4567', '+44 20 7123 4567'])).toBe('phone')
437-
expect(inferColumnType(['(555) 123-4567', '(555) 987-6543'])).toBe('phone')
438-
// An ID column of bare digits is a Number, which is the right answer.
439-
expect(inferColumnType(['5551234567', '5559876543'])).toBe('number')
440-
// Dash-separated digit runs are ISBNs and SKUs as often as phone numbers,
441-
// and a phone column keeps only the digits — so these stay text.
442-
expect(inferColumnType(['978-0-13-235088-4', '978-0-32-135668-0'])).toBe('string')
464+
it('keeps a select option clear through a retype, like every other key', () => {
465+
const from: ColumnDefinition = {
466+
name: 'c',
467+
type: 'select',
468+
options: [{ id: 'o1', name: 'One' }],
469+
}
470+
// Silence carries the old options across.
471+
const carried = buildConvertedColumn(
472+
from,
473+
{ tableId: 't', columnName: 'c', newType: 'select' },
474+
{ isSelectType: true, targetMultiple: false }
475+
)
476+
expect(carried.options).toEqual([{ id: 'o1', name: 'One' }])
477+
// An explicit clear does not silently restore them.
478+
const cleared = buildConvertedColumn(
479+
from,
480+
{ tableId: 't', columnName: 'c', newType: 'select', options: null },
481+
{ isSelectType: true, targetMultiple: false }
482+
)
483+
expect(cleared.options).toBeUndefined()
484+
})
485+
486+
it('shows a non-numeric percent cell rather than hiding it', () => {
487+
// Returning '' made a drifted cell look empty, which reads as data loss.
488+
expect(COLUMN_TYPE_REGISTRY.percent.formatForDisplay('n/a', column('percent'))).toBe('n/a')
489+
})
490+
491+
it('declares canonicalization for boolean and select, which both transform', () => {
492+
expect(COLUMN_TYPE_REGISTRY.boolean.canonicalizesValues).toBe(true)
493+
expect(COLUMN_TYPE_REGISTRY.select.canonicalizesValues).toBe(true)
494+
// Range comparison on opaque option ids is meaningless.
495+
expect(COLUMN_TYPE_REGISTRY.select.orderable).toBe(false)
496+
})
497+
498+
it('normalizes a phone SEARCH FRAGMENT so a substring filter can meet the stored value', () => {
499+
const contains = (value: string) =>
500+
filterRulesToFilter(
501+
[{ id: 'r1', logicalOperator: 'and' as const, column: 'c', operator: 'contains', value }],
502+
[{ id: 'c', name: 'c', type: 'phone' }]
503+
)
504+
// Stored as +442071234567; a formatted fragment must be stripped to meet it.
505+
expect(contains('+44 20 7123')).toEqual({ c: { $contains: '+44207123' } })
506+
expect(contains('(555) 123')).toEqual({ c: { $contains: '555123' } })
507+
// A fragment with NO digits is left alone — reducing it to '' would make
508+
// ILIKE '%%' match every row.
509+
expect(contains('abc')).toEqual({ c: { $contains: 'abc' } })
443510
})
444511
})
445512

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ export const booleanColumnType: ColumnTypeDefinition = {
77
label: 'Boolean',
88
icon: TypeBoolean,
99
jsonbCast: null,
10-
canonicalizesValues: false,
10+
// `coerce` folds case and trims (' True ' -> true), so a filter operand has
11+
// to go through it too.
12+
canonicalizesValues: true,
1113
orderable: false,
1214
storesOpaqueIds: false,
1315
supportsUnique: true,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ export const percentColumnType: ColumnTypeDefinition = {
6060
},
6161

6262
formatForDisplay(value, column) {
63-
if (typeof value !== 'number') return ''
63+
// A non-number renders as itself, matching `number`. Returning '' hid the
64+
// cell's actual contents, which reads as data loss rather than as drift.
65+
if (typeof value !== 'number') return String(value ?? '')
6466
return `${formatWithPrecision(value, column.precision)}%`
6567
},
6668

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ function normalizePhone(raw: string): string | null {
5555
return `${hasPlus ? '+' : ''}${digits}`
5656
}
5757

58+
/**
59+
* Strips a search fragment the same way a stored number was stripped, so a
60+
* substring filter can meet it.
61+
*
62+
* A fragment with NO digits is returned untouched. Reducing it to `''` would
63+
* make the resulting `ILIKE '%%'` match every row in the table — turning a
64+
* filter that should find nothing into one that finds everything.
65+
*/
66+
function normalizePhoneFragment(fragment: string): string {
67+
const trimmed = fragment.trim()
68+
const digits = trimmed.replace(/\D/g, '')
69+
if (digits === '') return fragment
70+
return `${trimmed.startsWith('+') ? '+' : ''}${digits}`
71+
}
72+
5873
export const phoneColumnType: ColumnTypeDefinition = {
5974
id: 'phone',
6075
label: 'Phone',
@@ -74,6 +89,8 @@ export const phoneColumnType: ColumnTypeDefinition = {
7489
typeaheadPattern: /[\d+\s\-().]/,
7590
parseErrorMessage: 'Invalid phone number',
7691

92+
normalizeFilterFragment: normalizePhoneFragment,
93+
7794
coerce(value) {
7895
// A number reaches here from a CSV whose phone column was read as numeric.
7996
if (typeof value === 'number') {

0 commit comments

Comments
 (0)