Skip to content

Commit 527753e

Browse files
committed
fix(tables): address Bugbot round 2
Both findings are the same hand-listed-key pattern the round-1 fix removed elsewhere, in code that round introduced. `addTableColumn` built a new column from `options`/`multiple` plus whatever `defaultMetadata` returned. `number` and `percent` deliberately declare no `defaultMetadata` for `precision` (absent has to keep meaning "render as stored"), so a precision the sidebar and the contract both accepted was dropped before it reached the schema. It now carries every key the target type owns, mirroring `buildConvertedColumn`. Toggling `includeTime` off runs a migration that truncates every stored cell, but the mutation treated the metadata path as schema-only and never invalidated row data — the grid kept rendering pre-migration timestamps while filters and exports saw the truncated values. Types now declare `metadataRewritesCells`, the client-safe counterpart to the server registry's `migrateCellsForMetadata`, and the hook reads it instead of naming keys. Nothing but a test couples those two halves, so there is now one asserting a type declares `metadataRewritesCells` exactly when it declares a migration.
1 parent c9fec0f commit 527753e

8 files changed

Lines changed: 105 additions & 4 deletions

File tree

apps/sim/hooks/queries/tables.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import type {
102102
WorkflowGroupOutput,
103103
} from '@/lib/table'
104104
import { getColumnId } from '@/lib/table/column-keys'
105+
import { metadataKeysIn, metadataRewritesCells } from '@/lib/table/column-types'
105106
import { TABLE_LIMITS } from '@/lib/table/constants'
106107
import {
107108
areGroupDepsSatisfied,
@@ -1401,9 +1402,21 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) {
14011402
// schema-only path would leave the cache holding pre-migration values,
14021403
// which the grid hides but emptiness checks, filters, and dependent-group
14031404
// eligibility still act on. Everything else really is metadata-only.
1405+
// `includeTime` going false truncates every stored cell in the migration
1406+
// the service runs, so the metadata path is NOT always schema-only. Which
1407+
// keys do that is declared by the type (`metadataRewritesCells`) rather
1408+
// than listed here, so a future key that rewrites cells invalidates rows
1409+
// without an edit at this call site.
1410+
const updatedColumn = context?.previousDetail?.schema.columns.find(
1411+
(c) => getColumnId(c) === variables.columnName || c.name === variables.columnName
1412+
)
1413+
const { generic, dedicated } = metadataKeysIn(variables.updates)
14041414
const rewritesRows =
14051415
variables.updates.type !== undefined ||
14061416
variables.updates.multiple !== undefined ||
1417+
(updatedColumn
1418+
? metadataRewritesCells(updatedColumn, [...generic, ...dedicated])
1419+
: false) ||
14071420
removesSelectOption(context?.previousDetail, variables)
14081421
if (rewritesRows) invalidateTableSchema(queryClient, tableId)
14091422
else invalidateTableSchemaOnly(queryClient, tableId)

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ownersOfMetadataKey,
2222
TYPE_SPECIFIC_COLUMN_KEYS,
2323
} from '@/lib/table/column-types'
24+
import { metadataMigrationFor } from '@/lib/table/column-types/registry.server'
2425
import type { ColumnDefinition } from '@/lib/table/types'
2526
import { validateColumnDefinition } from '@/lib/table/validation'
2627

@@ -113,6 +114,28 @@ describe('metadata key ownership', () => {
113114
})
114115
})
115116

117+
describe('client/server registry coupling', () => {
118+
it('declares metadataRewritesCells exactly when the server half migrates', () => {
119+
// The two halves are coupled by nothing but this assertion. A type that
120+
// migrates cells without declaring it leaves clients showing stale rows
121+
// after a metadata edit; declaring it without a migration makes them
122+
// refetch for nothing.
123+
for (const definition of ALL_COLUMN_TYPES) {
124+
const declares = (definition.metadataRewritesCells ?? []).length > 0
125+
const migrates = metadataMigrationFor(definition.id) !== undefined
126+
expect(declares, `${definition.id} declares=${declares} migrates=${migrates}`).toBe(migrates)
127+
}
128+
})
129+
130+
it('only names keys it actually owns as cell-rewriting', () => {
131+
for (const definition of ALL_COLUMN_TYPES) {
132+
for (const key of definition.metadataRewritesCells ?? []) {
133+
expect(definition.ownedMetadata).toContain(key)
134+
}
135+
}
136+
})
137+
})
138+
116139
describe('conversion write-back', () => {
117140
// A retype is allowed exactly when the target's `coerce` accepts the value,
118141
// and `coerce` often TRANSFORMS it. The conversion must therefore write the

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import {
1414
COLUMN_TYPE_REGISTRY,
1515
columnTypeById,
1616
isValueCompatible,
17+
metadataRewritesCells,
18+
metadataWithoutClears,
19+
ownedKeysOf,
1720
ownersOfMetadataKey,
21+
pickMetadata,
1822
} from '@/lib/table/column-types'
1923
import { coerceValue } from '@/lib/table/import'
2024
import { filterRulesToFilter } from '@/lib/table/query-builder/converters'
@@ -271,6 +275,27 @@ describe('review-round regressions', () => {
271275
})
272276
})
273277

278+
describe('review-round-2 regressions', () => {
279+
it("carries a create payload's precision onto the saved column", () => {
280+
// `addTableColumn` named options/multiple explicitly and otherwise relied on
281+
// `defaultMetadata`, which `number`/`percent` deliberately do not declare —
282+
// so a precision accepted by the sidebar and the contract was dropped before
283+
// it ever reached the schema.
284+
const owned = ownedKeysOf('percent')
285+
expect(owned).toContain('precision')
286+
expect(metadataWithoutClears(pickMetadata({ precision: 2 }, owned))).toEqual({ precision: 2 })
287+
})
288+
289+
it('marks a date-only toggle as rewriting cells so clients refetch rows', () => {
290+
// The migration truncates every stored time server-side; a client that
291+
// treated it as schema-only kept rendering pre-migration values.
292+
expect(metadataRewritesCells(column('date'), ['includeTime'])).toBe(true)
293+
// A purely presentational key must NOT force a row refetch.
294+
expect(metadataRewritesCells(column('currency'), ['currencyCode'])).toBe(false)
295+
expect(metadataRewritesCells(column('number'), ['precision'])).toBe(false)
296+
})
297+
})
298+
274299
describe('date includeTime', () => {
275300
it('truncates to a calendar day only when includeTime is explicitly false', () => {
276301
const dateOnly = column('date', { includeTime: false })

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export const dateColumnType: ColumnTypeDefinition = {
5151
supportsUnique: true,
5252
sampleValue: '2024-01-31',
5353
ownedMetadata: ownedKeysOf('date'),
54+
// Turning `includeTime` off truncates every stored cell.
55+
metadataRewritesCells: ['includeTime'],
5456
workflowInputType: 'string',
5557
editor: 'date',
5658
expandable: false,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@ export type {
1818
ColumnTypeDefinition,
1919
TypeSpecificColumnKey,
2020
} from '@/lib/table/column-types/types'
21-
export { TYPE_SPECIFIC_COLUMN_KEYS } from '@/lib/table/column-types/types'
21+
export {
22+
METADATA_KEY_OWNERS,
23+
ownedKeysOf,
24+
REQUIRED_METADATA_KEYS,
25+
TYPE_SPECIFIC_COLUMN_KEYS,
26+
} from '@/lib/table/column-types/types'

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ export function metadataWithoutClears(patch: ColumnMetadataPatch): ColumnTypeMet
223223
return resolved
224224
}
225225

226+
/**
227+
* Whether writing `keys` on `column` rewrites its stored cells (rather than
228+
* only how they render), so a client knows to invalidate cached row data.
229+
*/
230+
export function metadataRewritesCells(
231+
column: ColumnDefinition,
232+
keys: readonly TypeSpecificColumnKey[]
233+
): boolean {
234+
const rewriting = columnTypeOf(column).metadataRewritesCells ?? []
235+
return keys.some((key) => rewriting.includes(key))
236+
}
237+
226238
/** Whether a column of `type` may carry `key`. */
227239
export function typeOwnsMetadataKey(type: string | undefined, key: TypeSpecificColumnKey): boolean {
228240
return columnTypeById(type).ownedMetadata.includes(key)

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,21 @@ export interface ColumnTypeDefinition {
260260
*/
261261
readonly genericMetadataUpdate?: readonly TypeSpecificColumnKey[]
262262

263+
/**
264+
* Owned keys whose change REWRITES stored cells, not just how they render.
265+
*
266+
* The client-safe half of `migrateCellsForMetadata` (which lives in the
267+
* server registry and cannot be imported here). Clients read it to decide
268+
* whether a metadata edit invalidates cached ROW data as well as the schema:
269+
* toggling a date column to date-only truncates every stored time
270+
* server-side, and a grid still holding the pre-migration values disagrees
271+
* with what filters and exports now see.
272+
*
273+
* `column-type-registry.test.ts` asserts this stays in step with the server
274+
* registry, since nothing else couples the two halves.
275+
*/
276+
readonly metadataRewritesCells?: readonly TypeSpecificColumnKey[]
277+
263278
/** Workflow/block param type a column of this type maps onto. */
264279
readonly workflowInputType: 'string' | 'number' | 'boolean' | 'object'
265280

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
columnTypeOf,
2121
isValueCompatible,
2222
metadataWithoutClears,
23+
ownedKeysOf,
2324
pickMetadata,
2425
TYPE_SPECIFIC_COLUMN_KEYS,
2526
type TypeSpecificColumnKey,
@@ -110,6 +111,7 @@ export async function addTableColumn(
110111
)
111112
}
112113

114+
const definition = columnTypeById(column.type)
113115
const newColumn: TableSchema['columns'][number] = {
114116
// Honor a caller-provided id (undo of a delete reuses the original id);
115117
// otherwise mint a fresh one.
@@ -118,9 +120,13 @@ export async function addTableColumn(
118120
type: column.type as TableSchema['columns'][number]['type'],
119121
required: column.required ?? false,
120122
unique: column.unique ?? false,
121-
...(column.options ? { options: column.options } : {}),
122-
...(column.multiple ? { multiple: true } : {}),
123-
...columnTypeById(column.type).defaultMetadata?.(column as ColumnDefinition),
123+
// Every key the TARGET type owns, then its defaults. Naming
124+
// `options`/`multiple` here meant a key whose type declares no
125+
// `defaultMetadata` was silently dropped on create — a `precision` was
126+
// accepted by the sidebar and the contract and then never reached the
127+
// saved schema. Mirrors `buildConvertedColumn`'s carry-back loop.
128+
...metadataWithoutClears(pickMetadata(column, ownedKeysOf(definition.id))),
129+
...definition.defaultMetadata?.(column as ColumnDefinition),
124130
}
125131

126132
const columnValidation = validateColumnDefinition(newColumn)

0 commit comments

Comments
 (0)