@@ -15,9 +15,13 @@ import { createLogger } from '@sim/logger'
1515import { and , count , eq , sql } from 'drizzle-orm'
1616import { columnMatchesRef , generateColumnId , getColumnId } from '@/lib/table/column-keys'
1717import { 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'
1923import { 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'
2125import { assertColumnDestructive , assertSchemaMutable } from '@/lib/table/mutation-locks'
2226import type { DbTransaction } from '@/lib/table/planner'
2327import { 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