packages feed

acolyte-core-0.1.0.0: src/Acolyte/Core/Versioning.hs

-- | Type-level API versioning with typed deltas.
--
-- Express API evolution as type-level change operations with compile-time
-- backward compatibility checking.
--
-- @
-- type V1 =
--   '[ Get '[ Lit "users" ] (Json [UserV1])
--    , Get '[ Lit "users", Capture Int ] (Json UserV1)
--    ]
--
-- type V2Changes =
--   '[ 'Added (Get '[ Lit "profiles", Capture Int ] (Json Profile))
--    , 'Replaced (Get '[ Lit "users", Capture Int ] (Json UserV1))
--                (Get '[ Lit "users", Capture Int ] (Json UserV2))
--    , 'Deprecated (Get '[ Lit "users" ] (Json [UserV1]))
--    ]
--
-- -- Compiles: no Removed changes, so backward compatible.
-- type V2 = VersionedApi V1 V2Changes (ApplyChanges V1 V2Changes)
-- @
module Acolyte.Core.Versioning
  ( -- * Change kind
    Change (..)
    -- * Versioned API type
  , VersionedApi
    -- * Type families
  , ApplyChanges
  , IsBackwardCompatible
    -- * Constraint alias
  , BackwardCompatible
  ) where

import Data.Kind (Type, Constraint)
import GHC.TypeLits (TypeError, ErrorMessage (..))

import Acolyte.Core.API (type (++))
import Acolyte.Core.Effect (Assert)


-- | A change to an API between versions.
data Change
  = Added Type        -- ^ New endpoint added
  | Removed Type      -- ^ Endpoint removed (breaking!)
  | Replaced Type Type -- ^ Endpoint replaced with a new version
  | Deprecated Type   -- ^ Endpoint marked for future removal (still works)


-- | A versioned API with its base, changes, and resolved endpoint list.
--
-- The three parameters enable:
-- * @base@ — the previous version's API type
-- * @changes@ — the list of changes applied
-- * @resolved@ — the resulting API type (computed via 'ApplyChanges')
type VersionedApi :: [Type] -> [Change] -> [Type] -> Type
data VersionedApi (base :: [Type]) (changes :: [Change]) (resolved :: [Type])


-- | Apply a list of changes to a base API, producing the new API.
--
-- * @Added e@ — appends @e@ to the API
-- * @Removed e@ — removes @e@ from the API (via 'Remove')
-- * @Replaced old new@ — removes @old@, appends @new@
-- * @Deprecated e@ — no change to the endpoint list (it still works)
type ApplyChanges :: [Type] -> [Change] -> [Type]
type family ApplyChanges (base :: [Type]) (changes :: [Change]) :: [Type] where
  ApplyChanges base '[] = base
  ApplyChanges base ('Added e       ': rest) = ApplyChanges (base ++ '[e]) rest
  ApplyChanges base ('Removed e     ': rest) = ApplyChanges (Remove e base) rest
  ApplyChanges base ('Replaced o n  ': rest) = ApplyChanges (Remove o base ++ '[n]) rest
  ApplyChanges base ('Deprecated _  ': rest) = ApplyChanges base rest


-- | Remove a type from a type-level list (first occurrence).
type Remove :: Type -> [Type] -> [Type]
type family Remove (x :: Type) (xs :: [Type]) :: [Type] where
  Remove _ '[]       = '[]
  Remove x (x ': ys) = ys
  Remove x (y ': ys) = y ': Remove x ys


-- | Check whether a list of changes is backward compatible.
--
-- A change set is backward compatible if it contains no 'Removed' changes.
-- Added, Replaced, and Deprecated are all considered backward compatible.
type IsBackwardCompatible :: [Change] -> Bool
type family IsBackwardCompatible (changes :: [Change]) :: Bool where
  IsBackwardCompatible '[]                    = 'True
  IsBackwardCompatible ('Removed _ ': _)      = 'False
  IsBackwardCompatible (_          ': rest)   = IsBackwardCompatible rest


-- | Constraint that a change set is backward compatible.
--
-- Produces a clear type error if any 'Removed' change is present.
type BackwardCompatible :: [Change] -> Constraint
type family BackwardCompatible (changes :: [Change]) :: Constraint where
  BackwardCompatible changes =
    Assert (IsBackwardCompatible changes)
      ( 'Text "API version change is not backward compatible."
        ':$$: 'Text "The change set contains a 'Removed' endpoint."
        ':$$: 'Text "Remove the 'Removed' change or use 'Deprecated' instead."
      )


-- Assert imported from Acolyte.Core.Effect