From 9a6d3e36d2fda05f17bab25f15eb7b35919b401b Mon Sep 17 00:00:00 2001 From: Charlon Date: Thu, 19 Mar 2026 19:07:46 +0700 Subject: [PATCH 1/3] fix: use old type name in Union migration type signatures for renamed types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Union type is renamed between versions (e.g. MoralType in V51 → CompanyType in V52), the migration generator was using the NEW type name for the OLD version in the generated type signature: migrate_LegalEntity_CompanyType : Evergreen.V51.LegalEntity.CompanyType -> ... But CompanyType doesn't exist in V51 - it was called MoralType. The correct signature should be: migrate_LegalEntity_CompanyType : Evergreen.V51.LegalEntity.MoralType -> ... The fix threads typeNameOld through migrateUnionDefinition → migrateUnionDefinition_, mirroring how migrateAliasDefinition already correctly handles this case for type alias renames. Co-Authored-By: Claude Opus 4.6 (1M context) --- extra/Lamdera/Evergreen/MigrationGenerator.hs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/extra/Lamdera/Evergreen/MigrationGenerator.hs b/extra/Lamdera/Evergreen/MigrationGenerator.hs index 3bb7b3fa9..3ff1f274f 100644 --- a/extra/Lamdera/Evergreen/MigrationGenerator.hs +++ b/extra/Lamdera/Evergreen/MigrationGenerator.hs @@ -172,7 +172,7 @@ coreTypeMigration typeDidChange oldVersion newVersion interfaces newModule typeN (Just typeDefOld@(Union mOld typeNameOld unionOld), Just (Union mNew typeNameNew unionNew)) -> do let - unionDefMigration = migrateUnionDefinition typeDefOld oldVersion newVersion newModule identifier typeName interfaces recursionSet [] [] unionNew + unionDefMigration = migrateUnionDefinition typeDefOld oldVersion newVersion newModule identifier typeNameOld interfaces recursionSet [] [] unionNew (MigrationNested migrationImpl imps subDefs) = unionDefMigration migration = T.concat ["\n ", migrationWrapperForType typeName, " ( ", migrationImpl, " old, Cmd.none )"] @@ -193,16 +193,18 @@ coreTypeMigration typeDidChange oldVersion newVersion interfaces newModule typeN -- A top level Custom Type definition i.e. `type Herp = Derp ...` migrateUnionDefinition :: TypeDef -> Int -> Int -> ModuleName.Canonical -> TypeIdentifier -> N.Name -> Interfaces -> RecursionSet -> TvarMap -> TvarMap -> Can.Union -> Migration -migrateUnionDefinition typeDefOld oldVersion newVersion scope identifier@(author, pkg, newModule, tipe) typeNameNew interfaces recursionSet tvarMapOld tvarMapNew newUnion = +migrateUnionDefinition typeDefOld oldVersion newVersion scope identifier@(author, pkg, newModule, tipe) typeNameOld interfaces recursionSet tvarMapOld tvarMapNew newUnion = + let typeNameNew = tipe + in case typeDefOld of - (Alias moduleNameOld typeNameOld aliasOld) -> + (Alias moduleNameOld typeNameOld_ aliasOld) -> unimplemented "" ("`" <> N.toText typeNameNew <> "` was a type alias, but now it's a custom type. I need you to write this migration.") - (Union moduleNameOld typeNameOld unionOld) -> - migrateUnionDefinition_ author pkg unionOld newUnion tvarMapOld tvarMapNew oldVersion newVersion typeNameNew newModule identifier (dropCan moduleNameOld) interfaces recursionSet scope + (Union moduleNameOld typeNameOld_ unionOld) -> + migrateUnionDefinition_ author pkg unionOld newUnion tvarMapOld tvarMapNew oldVersion newVersion typeNameOld typeNameNew newModule identifier (dropCan moduleNameOld) interfaces recursionSet scope -migrateUnionDefinition_ :: Pkg.Author -> Pkg.Project -> Can.Union -> Can.Union -> TvarMap -> TvarMap -> Int -> Int -> N.Name -> N.Name -> TypeIdentifier -> N.Name -> Interfaces -> RecursionSet -> ModuleName.Canonical -> Migration -migrateUnionDefinition_ author pkg oldUnion newUnion tvarMapOld tvarMapNew oldVersion newVersion typeName newModule identifier oldModuleName interfaces recursionSet scope = +migrateUnionDefinition_ :: Pkg.Author -> Pkg.Project -> Can.Union -> Can.Union -> TvarMap -> TvarMap -> Int -> Int -> N.Name -> N.Name -> N.Name -> TypeIdentifier -> N.Name -> Interfaces -> RecursionSet -> ModuleName.Canonical -> Migration +migrateUnionDefinition_ author pkg oldUnion newUnion tvarMapOld tvarMapNew oldVersion newVersion typeNameOld typeName newModule identifier oldModuleName interfaces recursionSet scope = let oldModuleNameCanonical :: ModuleName.Canonical oldModuleNameCanonical = ModuleName.Canonical (Pkg.Name author pkg) oldModuleName @@ -276,7 +278,7 @@ migrateUnionDefinition_ author pkg oldUnion newUnion tvarMapOld tvarMapNew oldVe migrationTypeSignature = T.concat [ paramMigrationFnsTypeSig & T.intercalate " -> " & suffixIfNonempty " -> " , " " - , oldModuleName & N.toText, ".", typeName & N.toText + , oldModuleName & N.toText, ".", typeNameOld & N.toText , " " , tvarsOld & fmap (\tvar -> T.concat [N.toText tvar, "_old"]) & T.intercalate " " , " -> " @@ -1051,7 +1053,7 @@ migrateTypeDef typeOld typeNew oldVersion newVersion interfaces tvarMapOld tvarM in -- @TODO use unionOld instead of typeDefOld -- @TODO pass params - migrateUnionDefinition typeDefOld oldVersion newVersion mNew identifier typeNameNew interfaces newRecursionSet tvarMapOld tvarMapNew unionNew + migrateUnionDefinition typeDefOld oldVersion newVersion mNew identifier typeNameOld interfaces newRecursionSet tvarMapOld tvarMapNew unionNew & debugMigrationIncludes_ "migrateTypeDef:Union" (typeOld, typeNew) -- @ADVANCED handle case where user is aliasing a custom type? From a4605ae2943575c29173ff3a271d0bfa5cf0bf5b Mon Sep 17 00:00:00 2001 From: Charlon Date: Thu, 19 Mar 2026 19:20:42 +0700 Subject: [PATCH 2/3] fix: wrap pipeline migrations in lambda for nested Dict/SeqDict When a migration for a container type parameter is a pipeline (e.g. nested SeqDict producing `SeqDict.toList |> List.map ... |> SeqDict.fromList`), it must be wrapped in a lambda before being passed as a function argument to Tuple.mapBoth, Tuple.mapFirst, List.map, etc. Without this fix, the generated code produces: Tuple.mapBoth (migrate_Email) (SeqDict.toList |> ...) which is syntactically invalid (pipeline used as a value argument). With this fix: Tuple.mapBoth (migrate_Email) (\v__ -> v__ |> SeqDict.toList |> ...) --- extra/Lamdera/Evergreen/MigrationGenerator.hs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/extra/Lamdera/Evergreen/MigrationGenerator.hs b/extra/Lamdera/Evergreen/MigrationGenerator.hs index 3ff1f274f..b52d453d6 100644 --- a/extra/Lamdera/Evergreen/MigrationGenerator.hs +++ b/extra/Lamdera/Evergreen/MigrationGenerator.hs @@ -922,6 +922,11 @@ typeToMigration oldVersion newVersion scope interfaces recursionSet_ typeNew@(Ca canToMigration oldVersion newVersion scope interfaces newRecursionSet p0 (Just (p0o)) tvarMapOld tvarMapNew anonymousValueRef in T.concat [ "(\\", anonymousValueRef, " -> ", migration, ")"] + else if "|>" `T.isInfixOf` migrate_p0 then + -- Pipeline migrations (e.g. nested Dict/SeqDict) must be wrapped in a + -- lambda so they can be passed as function arguments to Tuple.mapBoth, + -- List.map, etc. + T.concat [ "(\\v__ -> v__ |> ", migrate_p0, ")" ] else T.concat [ migrate_p0 ] From ee65f8955eb096aba6cc1606d43e8f59d6f440cd Mon Sep 17 00:00:00 2001 From: Charlon Date: Mon, 27 Apr 2026 16:06:06 +0700 Subject: [PATCH 3/3] test: add scenarios for Union rename and nested Dict pipeline fixes Migrate_Union_Renamed verifies the V_old type signature uses the old type name when a Union is renamed across versions. Migrate_Nested_Dict verifies that pipeline migrations passed as arguments to Tuple.mapBoth are wrapped in a lambda. Also tightens the pipeline-wrap heuristic to skip migrations that are already parenthesised (e.g. tuple-destructuring lambdas), which was double-wrapping legitimate cases like Migrate_All's UserListTriple. --- extra/Lamdera/Evergreen/MigrationGenerator.hs | 9 ++-- .../Evergreen/TestMigrationGenerator.hs | 2 + .../src/Migrate_Nested_Dict/Actual.elm | 35 +++++++++++++++ .../src/Migrate_Nested_Dict/Expected.elm | 35 +++++++++++++++ .../src/Migrate_Nested_Dict/New.elm | 7 +++ .../src/Migrate_Nested_Dict/Old.elm | 7 +++ .../src/Migrate_Union_Renamed/Actual.elm | 45 +++++++++++++++++++ .../src/Migrate_Union_Renamed/Expected.elm | 45 +++++++++++++++++++ .../src/Migrate_Union_Renamed/New.elm | 10 +++++ .../src/Migrate_Union_Renamed/Old.elm | 10 +++++ 10 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 test/scenario-migration-generate/src/Migrate_Nested_Dict/Actual.elm create mode 100644 test/scenario-migration-generate/src/Migrate_Nested_Dict/Expected.elm create mode 100644 test/scenario-migration-generate/src/Migrate_Nested_Dict/New.elm create mode 100644 test/scenario-migration-generate/src/Migrate_Nested_Dict/Old.elm create mode 100644 test/scenario-migration-generate/src/Migrate_Union_Renamed/Actual.elm create mode 100644 test/scenario-migration-generate/src/Migrate_Union_Renamed/Expected.elm create mode 100644 test/scenario-migration-generate/src/Migrate_Union_Renamed/New.elm create mode 100644 test/scenario-migration-generate/src/Migrate_Union_Renamed/Old.elm diff --git a/extra/Lamdera/Evergreen/MigrationGenerator.hs b/extra/Lamdera/Evergreen/MigrationGenerator.hs index b52d453d6..134e0da3d 100644 --- a/extra/Lamdera/Evergreen/MigrationGenerator.hs +++ b/extra/Lamdera/Evergreen/MigrationGenerator.hs @@ -922,10 +922,11 @@ typeToMigration oldVersion newVersion scope interfaces recursionSet_ typeNew@(Ca canToMigration oldVersion newVersion scope interfaces newRecursionSet p0 (Just (p0o)) tvarMapOld tvarMapNew anonymousValueRef in T.concat [ "(\\", anonymousValueRef, " -> ", migration, ")"] - else if "|>" `T.isInfixOf` migrate_p0 then - -- Pipeline migrations (e.g. nested Dict/SeqDict) must be wrapped in a - -- lambda so they can be passed as function arguments to Tuple.mapBoth, - -- List.map, etc. + else if not (T.isPrefixOf "(" migrate_p0) && "|>" `T.isInfixOf` migrate_p0 then + -- Raw pipeline migrations (e.g. nested Dict/SeqDict) must be wrapped + -- in a lambda so they can be passed as function arguments to + -- Tuple.mapBoth, List.map, etc. Already-parenthesised expressions + -- (e.g. lambdas like `(\(t1,t2,t3) -> ...)`) are skipped. T.concat [ "(\\v__ -> v__ |> ", migrate_p0, ")" ] else T.concat [ migrate_p0 ] diff --git a/test/Test/Lamdera/Evergreen/TestMigrationGenerator.hs b/test/Test/Lamdera/Evergreen/TestMigrationGenerator.hs index ac2b92785..8ff40ffbe 100644 --- a/test/Test/Lamdera/Evergreen/TestMigrationGenerator.hs +++ b/test/Test/Lamdera/Evergreen/TestMigrationGenerator.hs @@ -114,6 +114,8 @@ testExamples = withTestEnv $ do -- , "src/Test/Migrate_External_Wrap.elm" "src/Migrate_External_Paramed" , "src/Migrate_All" + , "src/Migrate_Union_Renamed" + , "src/Migrate_Nested_Dict" ] catchTestException :: FilePath -> SomeException -> IO a diff --git a/test/scenario-migration-generate/src/Migrate_Nested_Dict/Actual.elm b/test/scenario-migration-generate/src/Migrate_Nested_Dict/Actual.elm new file mode 100644 index 000000000..34aac099d --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Nested_Dict/Actual.elm @@ -0,0 +1,35 @@ +module Migrate_Nested_Dict.Actual exposing (..) + +{-| This migration file was automatically generated by the lamdera compiler. + +It includes: + + - A migration for each of the 6 Lamdera core types that has changed + - A function named `migrate_ModuleName_TypeName` for each changed/custom type + +Expect to see: + + - `Unimplementеd` values as placeholders wherever I was unable to figure out a clear migration path for you + - `@NOTICE` comments for things you should know about, i.e. new custom type constructors that won't get any + value mappings from the old type by default + +You can edit this file however you wish! It won't be generated again. + +See for more info. + +-} + +import Lamdera.Migrations exposing (..) +import Migrate_Nested_Dict.New +import Migrate_Nested_Dict.Old + + +target = + migrate_Migrate_Nested_Dict_New_Target + + +migrate_Migrate_Nested_Dict_New_Target : Migrate_Nested_Dict.Old.Target -> Migrate_Nested_Dict.New.Target +migrate_Migrate_Nested_Dict_New_Target old = + case old of + Migrate_Nested_Dict.Old.Wrapper p0 -> + Migrate_Nested_Dict.New.Wrapper (p0 |> Dict.toList |> List.map (Tuple.mapBoth (Unimplemented {- Type changed from `Int` to `String`. I need you to write this migration. -}) (Dict.toList |> List.map (Tuple.mapFirst (Unimplemented {- Type changed from `Int` to `String`. I need you to write this migration. -})) |> Dict.fromList)) |> Dict.fromList) diff --git a/test/scenario-migration-generate/src/Migrate_Nested_Dict/Expected.elm b/test/scenario-migration-generate/src/Migrate_Nested_Dict/Expected.elm new file mode 100644 index 000000000..9d18dd107 --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Nested_Dict/Expected.elm @@ -0,0 +1,35 @@ +module Migrate_Nested_Dict.Expected exposing (..) + +{-| This migration file was automatically generated by the lamdera compiler. + +It includes: + + - A migration for each of the 6 Lamdera core types that has changed + - A function named `migrate_ModuleName_TypeName` for each changed/custom type + +Expect to see: + + - `Unimplementеd` values as placeholders wherever I was unable to figure out a clear migration path for you + - `@NOTICE` comments for things you should know about, i.e. new custom type constructors that won't get any + value mappings from the old type by default + +You can edit this file however you wish! It won't be generated again. + +See for more info. + +-} + +import Lamdera.Migrations exposing (..) +import Migrate_Nested_Dict.New +import Migrate_Nested_Dict.Old + + +target = + migrate_Migrate_Nested_Dict_New_Target + + +migrate_Migrate_Nested_Dict_New_Target : Migrate_Nested_Dict.Old.Target -> Migrate_Nested_Dict.New.Target +migrate_Migrate_Nested_Dict_New_Target old = + case old of + Migrate_Nested_Dict.Old.Wrapper p0 -> + Migrate_Nested_Dict.New.Wrapper (p0 |> Dict.toList |> List.map (Tuple.mapBoth (Unimplemented {- Type changed from `Int` to `String`. I need you to write this migration. -}) (\v__ -> v__ |> Dict.toList |> List.map (Tuple.mapFirst (Unimplemented {- Type changed from `Int` to `String`. I need you to write this migration. -})) |> Dict.fromList)) |> Dict.fromList) diff --git a/test/scenario-migration-generate/src/Migrate_Nested_Dict/New.elm b/test/scenario-migration-generate/src/Migrate_Nested_Dict/New.elm new file mode 100644 index 000000000..05212c628 --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Nested_Dict/New.elm @@ -0,0 +1,7 @@ +module Migrate_Nested_Dict.New exposing (..) + +import Dict exposing (Dict) + + +type Target + = Wrapper (Dict String (Dict String Int)) diff --git a/test/scenario-migration-generate/src/Migrate_Nested_Dict/Old.elm b/test/scenario-migration-generate/src/Migrate_Nested_Dict/Old.elm new file mode 100644 index 000000000..0fb064a8a --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Nested_Dict/Old.elm @@ -0,0 +1,7 @@ +module Migrate_Nested_Dict.Old exposing (..) + +import Dict exposing (Dict) + + +type Target + = Wrapper (Dict Int (Dict Int Int)) diff --git a/test/scenario-migration-generate/src/Migrate_Union_Renamed/Actual.elm b/test/scenario-migration-generate/src/Migrate_Union_Renamed/Actual.elm new file mode 100644 index 000000000..e7be24eaa --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Union_Renamed/Actual.elm @@ -0,0 +1,45 @@ +module Migrate_Union_Renamed.Actual exposing (..) + +{-| This migration file was automatically generated by the lamdera compiler. + +It includes: + + - A migration for each of the 6 Lamdera core types that has changed + - A function named `migrate_ModuleName_TypeName` for each changed/custom type + +Expect to see: + + - `Unimplementеd` values as placeholders wherever I was unable to figure out a clear migration path for you + - `@NOTICE` comments for things you should know about, i.e. new custom type constructors that won't get any + value mappings from the old type by default + +You can edit this file however you wish! It won't be generated again. + +See for more info. + +-} + +import Lamdera.Migrations exposing (..) +import Migrate_Union_Renamed.New +import Migrate_Union_Renamed.Old + + +target = + migrate_Migrate_Union_Renamed_New_Target + + +migrate_Migrate_Union_Renamed_New_CompanyType : Migrate_Union_Renamed.Old.CompanyType -> Migrate_Union_Renamed.New.CompanyType +migrate_Migrate_Union_Renamed_New_CompanyType old = + case old of + Migrate_Union_Renamed.Old.Individual -> + Migrate_Union_Renamed.New.Individual + + Migrate_Union_Renamed.Old.Corporation -> + Migrate_Union_Renamed.New.Corporation + + +migrate_Migrate_Union_Renamed_New_Target : Migrate_Union_Renamed.Old.Target -> Migrate_Union_Renamed.New.Target +migrate_Migrate_Union_Renamed_New_Target old = + case old of + Migrate_Union_Renamed.Old.Wrapper p0 -> + Migrate_Union_Renamed.New.Wrapper (p0 |> migrate_Migrate_Union_Renamed_New_CompanyType) diff --git a/test/scenario-migration-generate/src/Migrate_Union_Renamed/Expected.elm b/test/scenario-migration-generate/src/Migrate_Union_Renamed/Expected.elm new file mode 100644 index 000000000..c11d6088f --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Union_Renamed/Expected.elm @@ -0,0 +1,45 @@ +module Migrate_Union_Renamed.Expected exposing (..) + +{-| This migration file was automatically generated by the lamdera compiler. + +It includes: + + - A migration for each of the 6 Lamdera core types that has changed + - A function named `migrate_ModuleName_TypeName` for each changed/custom type + +Expect to see: + + - `Unimplementеd` values as placeholders wherever I was unable to figure out a clear migration path for you + - `@NOTICE` comments for things you should know about, i.e. new custom type constructors that won't get any + value mappings from the old type by default + +You can edit this file however you wish! It won't be generated again. + +See for more info. + +-} + +import Lamdera.Migrations exposing (..) +import Migrate_Union_Renamed.New +import Migrate_Union_Renamed.Old + + +target = + migrate_Migrate_Union_Renamed_New_Target + + +migrate_Migrate_Union_Renamed_New_CompanyType : Migrate_Union_Renamed.Old.MoralType -> Migrate_Union_Renamed.New.CompanyType +migrate_Migrate_Union_Renamed_New_CompanyType old = + case old of + Migrate_Union_Renamed.Old.Individual -> + Migrate_Union_Renamed.New.Individual + + Migrate_Union_Renamed.Old.Corporation -> + Migrate_Union_Renamed.New.Corporation + + +migrate_Migrate_Union_Renamed_New_Target : Migrate_Union_Renamed.Old.Target -> Migrate_Union_Renamed.New.Target +migrate_Migrate_Union_Renamed_New_Target old = + case old of + Migrate_Union_Renamed.Old.Wrapper p0 -> + Migrate_Union_Renamed.New.Wrapper (p0 |> migrate_Migrate_Union_Renamed_New_CompanyType) diff --git a/test/scenario-migration-generate/src/Migrate_Union_Renamed/New.elm b/test/scenario-migration-generate/src/Migrate_Union_Renamed/New.elm new file mode 100644 index 000000000..b92924ae4 --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Union_Renamed/New.elm @@ -0,0 +1,10 @@ +module Migrate_Union_Renamed.New exposing (..) + + +type Target + = Wrapper CompanyType + + +type CompanyType + = Individual + | Corporation diff --git a/test/scenario-migration-generate/src/Migrate_Union_Renamed/Old.elm b/test/scenario-migration-generate/src/Migrate_Union_Renamed/Old.elm new file mode 100644 index 000000000..6afda7c7e --- /dev/null +++ b/test/scenario-migration-generate/src/Migrate_Union_Renamed/Old.elm @@ -0,0 +1,10 @@ +module Migrate_Union_Renamed.Old exposing (..) + + +type Target + = Wrapper MoralType + + +type MoralType + = Individual + | Corporation