keiro-dsl-0.2.0.0: src/Keiro/Dsl/Diff.hs
{- | The spec evolution differ. 'diffSpecs' compares an /old/ and a /new/ 'Spec'
and classifies changes over the persisted decode and identity surfaces.
Changes are __ADDITIVE__ when they preserve stored data, __WARNING__ when they
change forward behaviour without invalidating persisted data, and __BREAKING__
when stored payloads may stop decoding or persisted identities may be re-keyed.
The @diff --since@ CLI exits non-zero only when a breaking change is present.
Every 'Node' constructor maps to a 'NodeFamily', and 'familyRegistry' contains
exactly one entry for each family. A family is either handled by an explicit
differ or carries a non-empty out-of-scope rationale. This makes omissions
visible when the grammar grows instead of silently treating new node kinds as
safe.
-}
module Keiro.Dsl.Diff (
Change (..),
ChangeKind (..),
isBreaking,
isAdvisory,
diffSpecs,
DiffEnv (..),
NodeFamily (..),
familyOf,
FamilyDiff (..),
familyRegistry,
Paired (..),
pairByName,
readModelDiff,
classifyWorkflowBody,
) where
import Data.List (find, (\\))
import Data.Maybe (isJust, isNothing, mapMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.Grammar
import Keiro.Dsl.ReadModelShape (registryNameFor, subscriptionNameFor)
import Keiro.Dsl.Validate (DiagnosticCode (..))
-- | A classified spec change.
data Change
= Additive ChangeKind
| Advisory ChangeKind
| Breaking ChangeKind
deriving stock (Eq, Show)
data ChangeKind = ChangeKind
{ ckNode :: !Name
, ckFacet :: !Text
, ckSubject :: !Text
, ckCode :: !(Maybe DiagnosticCode)
, ckDetail :: !Text
}
deriving stock (Eq, Show)
isBreaking :: Change -> Bool
isBreaking (Breaking _) = True
isBreaking (Additive _) = False
isBreaking (Advisory _) = False
isAdvisory :: Change -> Bool
isAdvisory (Advisory _) = True
isAdvisory (Additive _) = False
isAdvisory (Breaking _) = False
-- | Both specs supplied to a node-family differ, always old then new.
data DiffEnv = DiffEnv
{ deOld :: !Spec
, deNew :: !Spec
}
deriving stock (Eq, Show)
-- | The closed set of node families currently present in 'Node'.
data NodeFamily
= FamAggregate
| FamProcess
| FamRouter
| FamContract
| FamIntake
| FamEmit
| FamPublisher
| FamWorkqueue
| FamPgmqDispatch
| FamReadModel
| FamWorkflow
| FamOperation
deriving stock (Eq, Ord, Show, Enum, Bounded)
-- | Total by construction: one explicit arm per 'Node' constructor.
familyOf :: Node -> NodeFamily
familyOf (NAggregate _) = FamAggregate
familyOf (NProcess _) = FamProcess
familyOf (NRouter _) = FamRouter
familyOf (NContract _) = FamContract
familyOf (NIntake _) = FamIntake
familyOf (NEmit _) = FamEmit
familyOf (NPublisher _) = FamPublisher
familyOf (NWorkqueue _) = FamWorkqueue
familyOf (NPgmqDispatch _) = FamPgmqDispatch
familyOf (NReadModel _) = FamReadModel
familyOf (NWorkflow _) = FamWorkflow
familyOf (NOperation _) = FamOperation
-- | A family either has a differ or an explicit reason it is not compared.
data FamilyDiff
= DiffFamily (DiffEnv -> [Change])
| OutOfDiffScope Text
-- | Pair the old and new declarations of one node family by stable name.
data Paired n = Paired
{ prMatched :: ![(n, n)]
, prAdded :: ![n]
, prRemoved :: ![n]
}
deriving stock (Eq, Show)
pairByName :: (Node -> Maybe n) -> (n -> Name) -> DiffEnv -> Paired n
pairByName project nameOf env =
Paired
{ prMatched =
[ (oldNode, newNode)
| newNode <- newNodes
, Just oldNode <- [find ((== nameOf newNode) . nameOf) oldNodes]
]
, prAdded =
[ newNode
| newNode <- newNodes
, isNothing (find ((== nameOf newNode) . nameOf) oldNodes)
]
, prRemoved =
[ oldNode
| oldNode <- oldNodes
, isNothing (find ((== nameOf oldNode) . nameOf) newNodes)
]
}
where
oldNodes = mapMaybe project (specNodes (deOld env))
newNodes = mapMaybe project (specNodes (deNew env))
{- | Registry invariant: every 'Node' constructor maps to a family via the
total 'familyOf' case, and every family occurs exactly once here. The unit
suite enforces registry coverage and non-empty out-of-scope rationales.
-}
familyRegistry :: [(NodeFamily, FamilyDiff)]
familyRegistry =
[ (FamAggregate, DiffFamily aggregateDiff)
, (FamProcess, DiffFamily processDiff)
, (FamRouter, DiffFamily routerDiff)
, (FamContract, DiffFamily contractDiff)
, (FamIntake, DiffFamily intakeDiff)
, (FamEmit, DiffFamily emitDiff)
, (FamPublisher, DiffFamily publisherDiff)
, (FamWorkqueue, DiffFamily workqueueDiff)
, (FamPgmqDispatch, DiffFamily pgmqDispatchDiff)
, (FamReadModel, DiffFamily readModelDiff)
, (FamWorkflow, DiffFamily workflowDiff)
, (FamOperation, OutOfDiffScope "operations own no persisted decode or identity surface; their references and workflow signal/await pairing are single-spec validation concerns")
]
diffSpecs :: Spec -> Spec -> [Change]
diffSpecs old new =
sharedDeclarationDiff env
++ concatMap (runFamily env . snd) familyRegistry
where
env = DiffEnv old new
runFamily :: DiffEnv -> FamilyDiff -> [Change]
runFamily env (DiffFamily f) = f env
runFamily _ (OutOfDiffScope _) = []
-- Rules are intentionally outside the decode/identity axes: they alter guard
-- behaviour but neither interpret stored bytes nor derive persisted keys.
-- Shared id and enum declarations become diffed in Milestones 2 and 4.
sharedDeclarationDiff :: DiffEnv -> [Change]
sharedDeclarationDiff env = enumDiff env ++ idDiff env
nodeAggregate :: Node -> Maybe Aggregate
nodeAggregate (NAggregate a) = Just a
nodeAggregate _ = Nothing
nodeProcess :: Node -> Maybe ProcessNode
nodeProcess (NProcess process) = Just process
nodeProcess _ = Nothing
nodeRouter :: Node -> Maybe RouterNode
nodeRouter (NRouter router) = Just router
nodeRouter _ = Nothing
nodeContract :: Node -> Maybe ContractNode
nodeContract (NContract contract) = Just contract
nodeContract _ = Nothing
nodeIntake :: Node -> Maybe IntakeNode
nodeIntake (NIntake intake) = Just intake
nodeIntake _ = Nothing
nodeEmit :: Node -> Maybe EmitNode
nodeEmit (NEmit emit) = Just emit
nodeEmit _ = Nothing
nodePublisher :: Node -> Maybe PublisherNode
nodePublisher (NPublisher publisher) = Just publisher
nodePublisher _ = Nothing
nodeWorkqueue :: Node -> Maybe WorkqueueNode
nodeWorkqueue (NWorkqueue workqueue) = Just workqueue
nodeWorkqueue _ = Nothing
nodePgmqDispatch :: Node -> Maybe PgmqDispatchNode
nodePgmqDispatch (NPgmqDispatch dispatch) = Just dispatch
nodePgmqDispatch _ = Nothing
nodeReadModel :: Node -> Maybe ReadModelNode
nodeReadModel (NReadModel readModel) = Just readModel
nodeReadModel _ = Nothing
nodeWorkflow :: Node -> Maybe WorkflowNode
nodeWorkflow (NWorkflow workflow) = Just workflow
nodeWorkflow _ = Nothing
{- | Router identity is replay-sensitive: the stable name and key feed every
target-keyed dispatch id, and the target selects the persisted stream family.
-}
routerDiff :: DiffEnv -> [Change]
routerDiff env =
concatMap (uncurry routerPairDiff) (prMatched paired)
++ [additive (rtId router) "router" (rtId router) "new router declaration" | router <- prAdded paired]
++ [breaking (rtId router) "router-identity" (rtId router) RouterStableNameChanged "router removed while replayable source events may still derive target-keyed dispatch ids from its stable identity" | router <- prRemoved paired]
where
paired = pairByName nodeRouter rtId env
routerPairDiff :: RouterNode -> RouterNode -> [Change]
routerPairDiff oldRouter newRouter = stableName ++ keyDerivation ++ target
where
nodeName = rtId newRouter
stableName =
[ breaking nodeName "router-stable-name" nodeName RouterStableNameChanged $
"router stable name changed from '" <> rtName oldRouter <> "' to '" <> rtName newRouter <> "'; every deterministicRouterCommandId is re-keyed, so redelivery can duplicate the full resolved fan-out"
| rtName oldRouter /= rtName newRouter
]
keyDerivation =
[ breaking nodeName "router-key" (corrField (rtKey newRouter)) DerivedIdentityChanged "router key field or derivation changed; replay derives different target dispatch ids"
| rtKey oldRouter /= rtKey newRouter
]
target =
[ breaking nodeName "router-target" (rtTarget newRouter) DerivedIdentityChanged "router target aggregate changed; replay addresses a different persisted stream family"
| rtTarget oldRouter /= rtTarget newRouter
]
readModelDiff :: DiffEnv -> [Change]
readModelDiff env =
concatMap (uncurry (readModelPairDiff env)) (prMatched paired)
++ concatMap addedReadModelDiff (prAdded paired)
++ concatMap removedReadModelDiff (prRemoved paired)
where
paired = pairByName nodeReadModel rmName env
readModelPairDiff :: DiffEnv -> ReadModelNode -> ReadModelNode -> [Change]
readModelPairDiff env oldReadModel newReadModel =
versionChanges
++ shapeChanges
++ identityChanges
++ feedChanges
++ consistencyChanges
++ scopeChanges
where
nodeName = rmName newReadModel
versionChanges
| rmVersion newReadModel < rmVersion oldReadModel =
[ breaking nodeName "read-model-version" nodeName ReadModelVersionDecreased ("version decreased from " <> tInt (rmVersion oldReadModel) <> " to " <> tInt (rmVersion newReadModel))
]
| rmVersion newReadModel > rmVersion oldReadModel =
[ additive nodeName "read-model-version" nodeName ("version increased from " <> tInt (rmVersion oldReadModel) <> " to " <> tInt (rmVersion newReadModel) <> "; register and rebuild the new shape before serving it")
]
| otherwise = []
oldShape = (rmColumns oldReadModel, rmShape oldReadModel)
newShape = (rmColumns newReadModel, rmShape newReadModel)
shapeChanges =
[ breaking nodeName "read-model-shape" nodeName ReadModelShapeChangedWithoutBump ("declared columns or captured shape hash changed at version " <> tInt (rmVersion newReadModel) <> "; bump version and rebuild")
| oldShape /= newShape
, rmVersion oldReadModel == rmVersion newReadModel
]
oldRegistry = registryNameFor (specContext (deOld env)) oldReadModel
newRegistry = registryNameFor (specContext (deNew env)) newReadModel
oldSubscription = subscriptionNameFor (specContext (deOld env)) oldReadModel
newSubscription = subscriptionNameFor (specContext (deNew env)) newReadModel
identityChanges =
[ breaking nodeName "read-model-identity" nodeName DerivedIdentityChanged ("registry name changed '" <> oldRegistry <> "' -> '" <> newRegistry <> "'; the old registration row is orphaned")
| oldRegistry /= newRegistry
]
++ [ breaking nodeName "read-model-table" nodeName DerivedIdentityChanged ("qualified table changed '" <> qualifiedIdentity oldReadModel <> "' -> '" <> qualifiedIdentity newReadModel <> "'; existing data remains under the old identity")
| (rmSchema oldReadModel, rmTable oldReadModel) /= (rmSchema newReadModel, rmTable newReadModel)
]
++ [ breaking nodeName "read-model-subscription" nodeName DerivedIdentityChanged ("subscription changed '" <> oldSubscription <> "' -> '" <> newSubscription <> "'; the worker cursor remains under the old identity")
| oldSubscription /= newSubscription
]
feedChanges =
[ breaking nodeName "read-model-feed" nodeName ReadModelFeedChanged ("feed changed " <> renderFeed (rmFeed oldReadModel) <> " -> " <> renderFeed (rmFeed newReadModel) <> "; projection wiring and rebuild identities changed")
| rmFeed oldReadModel /= rmFeed newReadModel
]
consistencyChanges = case (rmConsistency oldReadModel, rmConsistency newReadModel) of
(Strong, Eventual) ->
[breaking nodeName "read-model-consistency" nodeName ReadModelConsistencyWeakened "default consistency changed Strong -> Eventual; callers lose the cursor-wait guarantee"]
(Eventual, Strong) ->
[additive nodeName "read-model-consistency" nodeName "default consistency changed Eventual -> Strong; callers gain a cursor-wait guarantee"]
_ -> []
oldScope = effectiveScope (rmScope oldReadModel)
newScope = effectiveScope (rmScope newReadModel)
scopeChanges
| oldScope == newScope = []
| scopeStrengthened oldScope newScope =
[additive nodeName "read-model-scope" nodeName ("Strong scope widened " <> renderScope oldScope <> " -> " <> renderScope newScope)]
| otherwise =
[breaking nodeName "read-model-scope" nodeName ReadModelConsistencyWeakened ("Strong scope changed " <> renderScope oldScope <> " -> " <> renderScope newScope <> "; callers no longer wait on the same event surface")]
addedReadModelDiff :: ReadModelNode -> [Change]
addedReadModelDiff readModel =
[additive (rmName readModel) "read-model" (rmName readModel) "new read model"]
removedReadModelDiff :: ReadModelNode -> [Change]
removedReadModelDiff readModel =
[breaking (rmName readModel) "read-model-identity" (rmName readModel) DerivedIdentityChanged "read model removed while registered metadata, data, subscription cursors, and callers may remain"]
qualifiedIdentity :: ReadModelNode -> Text
qualifiedIdentity readModel = rmSchema readModel <> "." <> rmTable readModel
renderFeed :: RmFeed -> Text
renderFeed RmInline = "inline"
renderFeed RmSubscription = "subscription"
effectiveScope :: Maybe RmScope -> RmScope
effectiveScope Nothing = RmEntireLog
effectiveScope (Just scope) = scope
scopeStrengthened :: RmScope -> RmScope -> Bool
scopeStrengthened (RmCategory _) RmEntireLog = True
scopeStrengthened _ _ = False
renderScope :: RmScope -> Text
renderScope RmEntireLog = "entire-log"
renderScope (RmCategory categoryName) = "category '" <> categoryName <> "'"
aggregateDiff :: DiffEnv -> [Change]
aggregateDiff env =
concatMap (uncurry aggregatePairDiff) (prMatched paired)
++ concatMap addedAggregateDiff (prAdded paired)
++ concatMap removedAggregateDiff (prRemoved paired)
where
paired = pairByName nodeAggregate aggName env
aggregatePairDiff :: Aggregate -> Aggregate -> [Change]
aggregatePairDiff oldAgg newAgg =
concatMap (eventDiff oldAgg newAgg) (aggEvents newAgg)
++ removedEvents oldAgg newAgg
++ wireDiff oldAgg newAgg
++ projectionDiff oldAgg newAgg
addedAggregateDiff :: Aggregate -> [Change]
addedAggregateDiff newAgg =
[ additive (aggName newAgg) "event" (evName e) "new event type (new aggregate)"
| e <- aggEvents newAgg
]
removedAggregateDiff :: Aggregate -> [Change]
removedAggregateDiff oldAgg =
[ breaking (aggName oldAgg) "event" (evName e) EvtRemovedNotDeprecated "aggregate removed; its event tags are no longer decodable"
| e <- aggEvents oldAgg
]
-- | Per-event classification for an event present in the new aggregate.
eventDiff :: Aggregate -> Aggregate -> Event -> [Change]
eventDiff oldAgg newAgg e =
case find ((== evName e) . evName) (aggEvents oldAgg) of
Nothing ->
[additive (aggName newAgg) "event" (evName e) "new event type"]
Just oldE
| evVersion e > evVersion oldE ->
if evVersion e == evVersion oldE + 1 && evUpcastFrom e `hasSource` evVersion oldE
then [additive (aggName newAgg) "event" (evName e) ("new version v" <> tInt (evVersion e) <> " with upcaster from v" <> tInt (evVersion oldE))]
else
[ breaking
(aggName newAgg)
"event"
(evName e)
EvtVersionMissingUpcaster
( "version changed from v"
<> tInt (evVersion oldE)
<> " to v"
<> tInt (evVersion e)
<> " without the required contiguous upcaster from v"
<> tInt (evVersion oldE)
)
]
| evVersion e < evVersion oldE ->
[breaking (aggName newAgg) "event" (evName e) EvtVersionDecreased ("version decreased from v" <> tInt (evVersion oldE) <> " to v" <> tInt (evVersion e))]
| otherwise ->
sameVersionEventDiff oldAgg newAgg oldE e
{- | Events present in the old aggregate but absent in the new one. Removing a
tag entirely is breaking; keeping it as a deprecated event is safe.
-}
removedEvents :: Aggregate -> Aggregate -> [Change]
removedEvents oldAgg newAgg =
[ breaking (aggName newAgg) "event" (evName oldE) EvtRemovedNotDeprecated "event removed entirely; keep it as a 'deprecated event' so old payloads still decode"
| oldE <- aggEvents oldAgg
, isNothing (find ((== evName oldE) . evName) (aggEvents newAgg))
]
hasSource :: Maybe (Int, Hole) -> Int -> Bool
hasSource (Just (m, _)) n = m == n
hasSource Nothing _ = False
eventFieldSigs :: Aggregate -> Event -> [(Name, Maybe Name)]
eventFieldSigs agg e = case evBody e of
EventFields fs -> map fieldSig fs
EventFromCommand cn ->
maybe [] (map fieldSig . cmdFields) (find ((== cn) . cmdName) (aggCommands agg))
where
fieldSig f = (fieldName f, fieldType f)
sameVersionEventDiff :: Aggregate -> Aggregate -> Event -> Event -> [Change]
sameVersionEventDiff oldAgg newAgg oldE newE =
addedChanges
++ removedChanges
++ typeChanges
++ deprecationChanges
where
oldFields = eventFieldSigs oldAgg oldE
newFields = eventFieldSigs newAgg newE
oldNames = map fst oldFields
newNames = map fst newFields
added = newNames \\ oldNames
removed = oldNames \\ newNames
changed =
[ (field, oldType, newType)
| (field, oldType) <- oldFields
, Just newType <- [lookup field newFields]
, oldType /= newType
]
addedChanges =
[ breaking (aggName newAgg) "event" (evName newE) EvtFieldAddedWithoutBump ("field(s) " <> commas added <> " added at the same version v" <> tInt (evVersion newE) <> " without a version bump or upcaster")
| not (null added)
]
removedChanges =
[ breaking (aggName newAgg) "event" (evName newE) EvtFieldRemovedSameVersion ("field(s) " <> commas removed <> " removed at the same version v" <> tInt (evVersion newE))
| not (null removed)
]
typeChanges =
[ breaking
(aggName newAgg)
"event-field"
(evName newE <> "." <> field)
EvtFieldTypeChanged
("type changed " <> renderFieldType oldType <> " -> " <> renderFieldType newType <> " at the same version v" <> tInt (evVersion newE))
| (field, oldType, newType) <- changed
]
deprecationChanges
| not (evDeprecated oldE) && evDeprecated newE =
[additive (aggName newAgg) "event" (evName newE) "event deprecated (still decodable)"]
| evDeprecated oldE && not (evDeprecated newE) =
[advisory (aggName newAgg) "event" (evName newE) EventUndeprecated "event returned to the write surface; old payloads remain decodable but new writes resume"]
| otherwise = []
renderFieldType :: Maybe Name -> Text
renderFieldType Nothing = "(declared)"
renderFieldType (Just name) = name
wireDiff :: Aggregate -> Aggregate -> [Change]
wireDiff oldAgg newAgg
| effectiveWire (aggWire oldAgg) == effectiveWire (aggWire newAgg) = []
| otherwise =
[ breaking
(aggName newAgg)
"wire"
(aggName newAgg)
WireSpecChanged
("effective wire convention changed " <> renderWire (effectiveWire (aggWire oldAgg)) <> " -> " <> renderWire (effectiveWire (aggWire newAgg)))
]
effectiveWire :: Maybe WireSpec -> (Text, Text)
effectiveWire Nothing = ("ctorName", "camelCase")
effectiveWire (Just w) = (wireKind w, wireFields w)
renderWire :: (Text, Text) -> Text
renderWire (kindName, fieldNames) = "kind=" <> kindName <> ", fields=" <> fieldNames
projectionDiff :: Aggregate -> Aggregate -> [Change]
projectionDiff oldAggregate newAggregate
| projectionSurface (aggProjection oldAggregate) == projectionSurface (aggProjection newAggregate) = []
| otherwise =
[ advisory
(aggName newAggregate)
"projection"
(aggName newAggregate)
ProjectionChanged
"projection table, consistency, key, or status mapping changed; coordinate the read-model migration"
]
projectionSurface :: Maybe ProjectionSpec -> Maybe (Name, Maybe Consistency, Name, Maybe Mapping)
projectionSurface projection = do
value <- projection
pure (projTable value, projConsistency value, projKey value, projStatusMap value)
idDiff :: DiffEnv -> [Change]
idDiff env =
concatMap (uncurry idPairDiff) (prMatched paired)
++ concatMap addedIdDiff (prAdded paired)
++ concatMap removedIdDiff (prRemoved paired)
where
paired = pairDeclarations idName (specIds (deOld env)) (specIds (deNew env))
idPairDiff :: IdDecl -> IdDecl -> [Change]
idPairDiff oldId newId =
[ breaking (idName newId) "id-prefix" (idName newId) IdPrefixChanged ("prefix changed '" <> idPrefix oldId <> "' -> '" <> idPrefix newId <> "'; stored and newly minted ids no longer share an identity domain")
| idPrefix oldId /= idPrefix newId
]
addedIdDiff :: IdDecl -> [Change]
addedIdDiff declaration = [additive (idName declaration) "id-prefix" (idName declaration) "new id declaration"]
removedIdDiff :: IdDecl -> [Change]
removedIdDiff declaration = [breaking (idName declaration) "id-prefix" (idName declaration) IdPrefixChanged "id declaration removed; persisted ids still use its prefix"]
enumDiff :: DiffEnv -> [Change]
enumDiff env =
concatMap (uncurry (enumPairDiff (deOld env))) (prMatched paired)
++ concatMap addedEnumDiff (prAdded paired)
++ concatMap (removedEnumDiff (deOld env)) (prRemoved paired)
where
paired = pairDeclarations enumName (specEnums (deOld env)) (specEnums (deNew env))
enumPairDiff :: Spec -> EnumDecl -> EnumDecl -> [Change]
enumPairDiff oldSpec oldEnum newEnum =
[ breaking (enumName newEnum) "enum-constructor" ctor EnumCtorRemoved ("constructor removed; stored wire value '" <> wire <> "' no longer decodes" <> enumUsageSuffix oldSpec (enumName oldEnum))
| (ctor, wire) <- enumCtors oldEnum
, isNothing (lookup ctor (enumCtors newEnum))
]
++ [ breaking (enumName newEnum) "enum-constructor" ctor EnumWireSpellingChanged ("wire spelling changed '" <> oldWire <> "' -> '" <> newWire <> "'; stored values using the old spelling no longer decode" <> enumUsageSuffix oldSpec (enumName oldEnum))
| (ctor, oldWire) <- enumCtors oldEnum
, Just newWire <- [lookup ctor (enumCtors newEnum)]
, oldWire /= newWire
]
++ [ additive (enumName newEnum) "enum-constructor" ctor ("new constructor with wire spelling '" <> wire <> "'")
| (ctor, wire) <- enumCtors newEnum
, isNothing (lookup ctor (enumCtors oldEnum))
]
addedEnumDiff :: EnumDecl -> [Change]
addedEnumDiff enumDecl =
[additive (enumName enumDecl) "enum-constructor" ctor ("new enum constructor with wire spelling '" <> wire <> "'") | (ctor, wire) <- enumCtors enumDecl]
removedEnumDiff :: Spec -> EnumDecl -> [Change]
removedEnumDiff oldSpec enumDecl =
[ breaking (enumName enumDecl) "enum-constructor" ctor EnumCtorRemoved ("enum removed; stored wire value '" <> wire <> "' no longer decodes" <> enumUsageSuffix oldSpec (enumName enumDecl))
| (ctor, wire) <- enumCtors enumDecl
]
enumUsageSuffix :: Spec -> Name -> Text
enumUsageSuffix spec enumType = case enumUsages spec enumType of
[] -> ""
usages -> "; used by " <> commas usages
enumUsages :: Spec -> Name -> [Text]
enumUsages spec enumType =
[aggName agg <> ".reg." <> regName reg | agg <- aggregates, reg <- aggRegs agg, regType reg == enumType]
++ [ aggName agg <> ".event." <> evName event <> "." <> field
| agg <- aggregates
, event <- aggEvents agg
, (field, Just fieldTypeName) <- eventFieldSigs agg event
, fieldTypeName == enumType
]
where
aggregates = [agg | NAggregate agg <- specNodes spec]
pairDeclarations :: (n -> Name) -> [n] -> [n] -> Paired n
pairDeclarations nameOf oldNodes newNodes =
Paired
{ prMatched =
[ (oldNode, newNode)
| newNode <- newNodes
, Just oldNode <- [find ((== nameOf newNode) . nameOf) oldNodes]
]
, prAdded = [newNode | newNode <- newNodes, isNothing (find ((== nameOf newNode) . nameOf) oldNodes)]
, prRemoved = [oldNode | oldNode <- oldNodes, isNothing (find ((== nameOf oldNode) . nameOf) newNodes)]
}
contractDiff :: DiffEnv -> [Change]
contractDiff env =
concatMap (uncurry contractPairDiff) (prMatched paired)
++ concatMap addedContractDiff (prAdded paired)
++ concatMap removedContractDiff (prRemoved paired)
where
paired = pairByName nodeContract ctrName env
contractPairDiff :: ContractNode -> ContractNode -> [Change]
contractPairDiff oldContract newContract =
schemaChanges
++ discriminatorChanges
++ topicChanges
++ concatMap eventPairChanges matchedEvents
++ concatMap addedEventChanges addedEvents
++ concatMap removedEventChanges removedEvents'
where
schemaChanges =
[ breaking
(ctrName newContract)
"schema-version"
(ctrName newContract)
ContractSchemaVersionDecreased
("schemaVersion decreased from " <> tInt (ctrSchemaVersion oldContract) <> " to " <> tInt (ctrSchemaVersion newContract))
| ctrSchemaVersion newContract < ctrSchemaVersion oldContract
]
discriminatorChanges =
[ breaking
(ctrName newContract)
"discriminator"
(ctrName newContract)
ContractDiscriminatorChanged
("discriminator changed " <> ctrDiscriminator oldContract <> " -> " <> ctrDiscriminator newContract)
| ctrDiscriminator oldContract /= ctrDiscriminator newContract
]
topicChanges = contractTopicDiff oldContract newContract
eventPairs = pairDeclarations ceName (ctrEvents oldContract) (ctrEvents newContract)
matchedEvents = prMatched eventPairs
addedEvents = prAdded eventPairs
removedEvents' = prRemoved eventPairs
eventPairChanges (oldEvent, newEvent) = contractEventDiff oldContract newContract oldEvent newEvent
addedEventChanges event =
[additive (ctrName newContract) "contract-event" (ceName event) "new contract event"]
removedEventChanges event =
[breaking (ctrName newContract) "contract-event" (ceName event) ContractEventRemoved "contract event removed; existing cross-service payloads no longer have a declared decoder"]
addedContractDiff :: ContractNode -> [Change]
addedContractDiff contract =
[additive (ctrName contract) "contract-event" (ceName event) "new event in a new contract" | event <- ctrEvents contract]
removedContractDiff :: ContractNode -> [Change]
removedContractDiff contract =
[breaking (ctrName contract) "contract-event" (ceName event) ContractEventRemoved "contract removed; its cross-service event decoder is no longer declared" | event <- ctrEvents contract]
contractTopicDiff :: ContractNode -> ContractNode -> [Change]
contractTopicDiff oldContract newContract =
[ breaking
(ctrName newContract)
"contract-topic"
alias
ContractTopicChanged
("topic alias removed; previous topic was '" <> oldTopic <> "'")
| (alias, oldTopic) <- ctrTopics oldContract
, isNothing (lookup alias (ctrTopics newContract))
]
++ [ breaking
(ctrName newContract)
"contract-topic"
alias
ContractTopicChanged
("real topic changed '" <> oldTopic <> "' -> '" <> newTopic <> "'")
| (alias, oldTopic) <- ctrTopics oldContract
, Just newTopic <- [lookup alias (ctrTopics newContract)]
, oldTopic /= newTopic
]
++ [ additive (ctrName newContract) "contract-topic" alias ("new topic alias for '" <> topic <> "'")
| (alias, topic) <- ctrTopics newContract
, isNothing (lookup alias (ctrTopics oldContract))
]
contractEventDiff :: ContractNode -> ContractNode -> ContractEvent -> ContractEvent -> [Change]
contractEventDiff oldContract newContract oldEvent newEvent =
topicAliasChange
++ removedFieldChanges
++ changedFieldChanges
++ addedFieldChanges
where
fieldPairs = pairDeclarations cfName (ceFields oldEvent) (ceFields newEvent)
topicAliasChange =
[ breaking
(ctrName newContract)
"contract-topic"
(ceName newEvent)
ContractTopicChanged
("event topic alias changed " <> ceTopic oldEvent <> " -> " <> ceTopic newEvent)
| ceTopic oldEvent /= ceTopic newEvent
]
removedFieldChanges =
[ breaking (ctrName newContract) "contract-field" (ceName newEvent <> "." <> cfName field) ContractFieldChanged "field removed; existing messages still carry the old contract shape"
| field <- prRemoved fieldPairs
]
changedFieldChanges =
[ breaking
(ctrName newContract)
"contract-field"
(ceName newEvent <> "." <> cfName newField)
ContractFieldChanged
("field type changed " <> renderContractType (cfType oldField) <> " -> " <> renderContractType (cfType newField))
| (oldField, newField) <- prMatched fieldPairs
, cfType oldField /= cfType newField
]
addedFieldChanges =
[ if ctrSchemaVersion newContract > ctrSchemaVersion oldContract
then advisory (ctrName newContract) "contract-field" subject ContractSchemaVersionBumped ("field added with schemaVersion bump " <> tInt (ctrSchemaVersion oldContract) <> " -> " <> tInt (ctrSchemaVersion newContract) <> "; coordinate the cross-service rollout")
else breaking (ctrName newContract) "contract-field" subject ContractFieldChanged "field added without a schemaVersion bump; older in-flight messages do not contain it"
| field <- prAdded fieldPairs
, let subject = ceName newEvent <> "." <> cfName field
]
renderContractType :: ContractType -> Text
renderContractType (CTypeId prefix) = "typeid '" <> prefix <> "'"
renderContractType CText = "text"
renderContractType CInt = "int"
workqueueDiff :: DiffEnv -> [Change]
workqueueDiff env =
concatMap (uncurry workqueuePairDiff) (prMatched paired)
++ concatMap addedWorkqueueDiff (prAdded paired)
++ concatMap removedWorkqueueDiff (prRemoved paired)
where
paired = pairByName nodeWorkqueue wqName env
workqueuePairDiff :: WorkqueueNode -> WorkqueueNode -> [Change]
workqueuePairDiff oldQueue newQueue =
concatMap pairedFieldDiff (prMatched fields)
++ concatMap addedFieldDiff (prAdded fields)
++ concatMap removedFieldDiff (prRemoved fields)
++ queueIdentityDiff oldQueue newQueue
++ queuePolicyDiff oldQueue newQueue
where
-- wqPayloadName is a generated Haskell type name, not a wire-visible name.
fields = pairDeclarations wqfName (wqPayload oldQueue) (wqPayload newQueue)
pairedFieldDiff (oldField, newField)
| wqfWire oldField /= wqfWire newField = [payloadBreaking newField ("wire name changed '" <> wqfWire oldField <> "' -> '" <> wqfWire newField <> "'")]
| wqfType oldField /= wqfType newField = [payloadBreaking newField ("type changed " <> wqfType oldField <> " -> " <> wqfType newField)]
| not (wqfRequired oldField) && wqfRequired newField = [payloadBreaking newField "field changed from optional to required; queued jobs may omit it"]
| wqfRequired oldField && not (wqfRequired newField) = [additive (wqName newQueue) "payload-field" (wqfName newField) "field changed from required to optional"]
| otherwise = []
addedFieldDiff field
| wqfRequired field = [payloadBreaking field "new required field; queued jobs do not contain it"]
| otherwise = [additive (wqName newQueue) "payload-field" (wqfName field) "new optional field"]
removedFieldDiff field = [payloadBreaking field "field removed; queued jobs still contain the old payload shape"]
payloadBreaking field detail = breaking (wqName newQueue) "payload-field" (wqfName field) WqPayloadFieldChanged detail
addedWorkqueueDiff :: WorkqueueNode -> [Change]
addedWorkqueueDiff queue =
[additive (wqName queue) "payload-field" (wqfName field) "field belongs to a new workqueue payload" | field <- wqPayload queue]
removedWorkqueueDiff :: WorkqueueNode -> [Change]
removedWorkqueueDiff queue =
[breaking (wqName queue) "payload-field" (wqfName field) WqPayloadFieldChanged "workqueue removed while persisted jobs may still carry this payload" | field <- wqPayload queue]
++ [breaking (wqName queue) "queue-identity" (wqName queue) QueueIdentityChanged "workqueue removed; its physical queue, DLQ, and pgmq table may still hold state"]
queueIdentityDiff :: WorkqueueNode -> WorkqueueNode -> [Change]
queueIdentityDiff oldQueue newQueue =
[ breaking
(wqName newQueue)
"queue-identity"
(wqName newQueue)
QueueIdentityChanged
"logical, physical, DLQ, or table name changed; queued jobs and dispatch dedupe records remain under the old identity"
| queueIdentity oldQueue /= queueIdentity newQueue
]
queueIdentity :: WorkqueueNode -> (Text, Text, Text, Text)
queueIdentity queue = (wqLogical queue, wqPhysical queue, wqDlq queue, wqTable queue)
queuePolicyDiff :: WorkqueueNode -> WorkqueueNode -> [Change]
queuePolicyDiff oldQueue newQueue = ordering ++ provision ++ groupKey
where
nodeName = wqName newQueue
ordering =
[ breaking nodeName "queue-ordering" nodeName WqOrderingChanged $
"ordering changed " <> renderWqOrdering (wqOrdering oldQueue) <> " -> " <> renderWqOrdering (wqOrdering newQueue) <> "; consumers were written against the old delivery-order contract"
| wqOrdering oldQueue /= wqOrdering newQueue
]
provision =
[ breaking nodeName "queue-provision" nodeName WqProvisionChanged $
"provision changed " <> renderWqProvision (wqProvision oldQueue) <> " -> " <> renderWqProvision (wqProvision newQueue) <> "; provisioning is create-time only, so migrate the existing queue operationally before changing the spec"
| wqProvision oldQueue /= wqProvision newQueue
]
groupKey =
[ breaking nodeName "queue-group-key" nodeName WqGroupKeyChanged $
"group key derivation changed " <> renderWqGroupKey (wqGroupKey oldQueue) <> " -> " <> renderWqGroupKey (wqGroupKey newQueue) <> "; FIFO messages are re-partitioned across durable ordering groups"
| wqGroupKey oldQueue /= wqGroupKey newQueue
]
renderWqOrdering :: WqOrdering -> Text
renderWqOrdering WqUnordered = "unordered"
renderWqOrdering WqFifoThroughput = "fifo-throughput"
renderWqOrdering WqFifoRoundRobin = "fifo-roundrobin"
renderWqProvision :: WqProvision -> Text
renderWqProvision WqStandard = "standard"
renderWqProvision WqUnlogged = "unlogged"
renderWqProvision (WqPartitioned interval duration) = "partitioned(interval=" <> interval <> ", retention=" <> duration <> ")"
renderWqGroupKey :: Maybe WqGroupKey -> Text
renderWqGroupKey Nothing = "none"
renderWqGroupKey (Just groupKey) =
gkField groupKey
<> " via "
<> gkVia groupKey
<> maybe "" (" fixture " <>) (gkFixture groupKey)
processDiff :: DiffEnv -> [Change]
processDiff env =
concatMap (uncurry processPairDiff) (prMatched paired)
++ concatMap addedProcessDiff (prAdded paired)
++ concatMap removedProcessDiff (prRemoved paired)
where
paired = pairByName nodeProcess procId env
processPairDiff :: ProcessNode -> ProcessNode -> [Change]
processPairDiff oldProcess newProcess =
concatMap pairedFieldDiff (prMatched fields)
++ map (fieldChange "field added; source events at the old shape cannot populate it") (prAdded fields)
++ map (fieldChange "field removed; the generated process input decoder changed") (prRemoved fields)
++ processIdentityDiff oldProcess newProcess
++ processTimerWindowDiff oldProcess newProcess
where
-- inName is a generated Haskell type name; the wire shape is inFields.
fields = pairDeclarations fieldName (inFields (procInput oldProcess)) (inFields (procInput newProcess))
pairedFieldDiff (oldField, newField)
| fieldType oldField /= fieldType newField = [fieldChange ("type changed " <> renderFieldType (fieldType oldField) <> " -> " <> renderFieldType (fieldType newField)) newField]
| otherwise = []
fieldChange detail field = breaking (procId newProcess) "input-field" (fieldName field) ProcessInputChanged (detail <> "; version the source event before changing process input")
addedProcessDiff :: ProcessNode -> [Change]
addedProcessDiff process =
[additive (procId process) "input-field" (fieldName field) "field belongs to a new process input" | field <- inFields (procInput process)]
removedProcessDiff :: ProcessNode -> [Change]
removedProcessDiff process =
[breaking (procId process) "input-field" (fieldName field) ProcessInputChanged "process removed while persisted source events may still require this input decoder" | field <- inFields (procInput process)]
++ [breaking (procId process) "derived-identity" (procId process) DerivedIdentityChanged "process removed while persisted saga, dispatch, and timer identities may still exist"]
processIdentityDiff :: ProcessNode -> ProcessNode -> [Change]
processIdentityDiff oldProcess newProcess =
[ breaking
(procId newProcess)
"derived-identity"
(procId newProcess)
DerivedIdentityChanged
"process name, correlation derivation, saga stream category, timer id prefix, or fired-event-id prefix changed; replays and retries no longer derive the persisted identity"
| processIdentity oldProcess /= processIdentity newProcess
]
processIdentity :: ProcessNode -> (Text, Name, Name, Text, Text, Text)
processIdentity process =
( procName process
, corrField (procCorrelate process)
, corrVia (procCorrelate process)
, sagaCategory (procSaga process)
, idePrefix (tmId (procTimer process))
, idePrefix (fireFiredEventId (tmFire (procTimer process)))
)
processTimerWindowDiff :: ProcessNode -> ProcessNode -> [Change]
processTimerWindowDiff oldProcess newProcess =
[ advisory
(procId newProcess)
"timer"
(tmName (procTimer newProcess))
TimerWindowChanged
( "fireAt source/window changed "
<> renderFireAt (tmFireAt (procTimer oldProcess))
<> " -> "
<> renderFireAt (tmFireAt (procTimer newProcess))
<> "; already-scheduled timers keep their persisted deadline"
)
| tmFireAt (procTimer oldProcess) /= tmFireAt (procTimer newProcess)
]
renderFireAt :: FireAtExpr -> Text
renderFireAt expression = "input." <> faField expression <> " + " <> faWindow expression
workflowDiff :: DiffEnv -> [Change]
workflowDiff env =
concatMap (uncurry workflowPairDiff) (prMatched paired)
++ concatMap addedWorkflowDiff (prAdded paired)
++ concatMap removedWorkflowDiff (prRemoved paired)
where
paired = pairByName nodeWorkflow wfId env
workflowPairDiff :: WorkflowNode -> WorkflowNode -> [Change]
workflowPairDiff oldWorkflow newWorkflow =
inputChanges
++ outputChanges
++ classifyWorkflowBody oldWorkflow newWorkflow
++ workflowIdentityDiff oldWorkflow newWorkflow
where
fields = pairDeclarations fieldName (wfInputFields oldWorkflow) (wfInputFields newWorkflow)
inputChanges =
[workflowShape field "input field added; journaled inputs at the old shape do not contain it" | field <- prAdded fields]
++ [workflowShape field "input field removed; journaled inputs still contain the old shape" | field <- prRemoved fields]
++ [ workflowShape newField ("input field type changed " <> renderFieldType (fieldType oldField) <> " -> " <> renderFieldType (fieldType newField))
| (oldField, newField) <- prMatched fields
, fieldType oldField /= fieldType newField
]
outputChanges =
[ breaking (wfId newWorkflow) "workflow-output" (wfOutput newWorkflow) WorkflowShapeChanged ("output type changed " <> wfOutput oldWorkflow <> " -> " <> wfOutput newWorkflow <> "; persisted outcomes may no longer decode")
| wfOutput oldWorkflow /= wfOutput newWorkflow
]
workflowShape field detail = breaking (wfId newWorkflow) "workflow-input" (fieldName field) WorkflowShapeChanged detail
addedWorkflowDiff :: WorkflowNode -> [Change]
addedWorkflowDiff workflow = [additive (wfId workflow) "workflow" (wfId workflow) "new workflow"]
removedWorkflowDiff :: WorkflowNode -> [Change]
removedWorkflowDiff workflow = [breaking (wfId workflow) "workflow" (wfId workflow) WorkflowShapeChanged "workflow removed while in-flight journals and outcomes may still require its decoder"]
workflowIdentityDiff :: WorkflowNode -> WorkflowNode -> [Change]
workflowIdentityDiff oldWorkflow newWorkflow =
[ breaking
(wfId newWorkflow)
"workflow-name"
(wfId newWorkflow)
WorkflowStableNameChanged
("stable name changed '" <> wfStable oldWorkflow <> "' -> '" <> wfStable newWorkflow <> "'; in-flight journals remain under the old stream name")
| wfStable oldWorkflow /= wfStable newWorkflow
]
++ [ breaking
(wfId newWorkflow)
"derived-identity"
(wfId newWorkflow)
DerivedIdentityChanged
"workflow id source field or derivation changed; journal and deterministic child/step identities no longer coalesce with persisted executions"
| (wfIdField oldWorkflow, wfIdVia oldWorkflow) /= (wfIdField newWorkflow, wfIdVia newWorkflow)
]
intakeDiff :: DiffEnv -> [Change]
intakeDiff env =
concatMap (uncurry intakePairDiff) (prMatched paired)
++ concatMap addedIntakeDiff (prAdded paired)
++ concatMap removedIntakeDiff (prRemoved paired)
where
paired = pairByName nodeIntake inkName env
intakePairDiff :: IntakeNode -> IntakeNode -> [Change]
intakePairDiff oldIntake newIntake =
[ breaking
(inkName newIntake)
"dedupe-identity"
(inkName newIntake)
DedupeIdentityChanged
"dedupe key or policy changed; redelivered messages no longer match their persisted dedupe record"
| (inkDedupeKey oldIntake, inkDedupePolicy oldIntake) /= (inkDedupeKey newIntake, inkDedupePolicy newIntake)
]
++ [ advisory
(inkName newIntake)
"decode-posture"
(inkName newIntake)
DecodePostureChanged
"envelope/body decode posture changed; future messages are accepted or rejected differently"
| inkDecode oldIntake /= inkDecode newIntake
]
++ [ advisory
(inkName newIntake)
"inbox-persistence"
(inkName newIntake)
IntakePersistenceChanged
("success-path envelope persistence changed " <> renderInkPersist (inkPersist oldIntake) <> " -> " <> renderInkPersist (inkPersist newIntake) <> "; existing rows are unchanged while future successful rows retain a different envelope shape")
| inkPersist oldIntake /= inkPersist newIntake
]
renderInkPersist :: InkPersist -> Text
renderInkPersist InkPersistFull = "full-envelope"
renderInkPersist InkPersistDedupeOnly = "dedupe-only"
addedIntakeDiff :: IntakeNode -> [Change]
addedIntakeDiff intake = [additive (inkName intake) "intake" (inkName intake) "new intake"]
removedIntakeDiff :: IntakeNode -> [Change]
removedIntakeDiff intake = [breaking (inkName intake) "dedupe-identity" (inkName intake) DedupeIdentityChanged "intake removed while persisted dedupe records and redeliveries may remain"]
emitDiff :: DiffEnv -> [Change]
emitDiff env =
concatMap (uncurry emitPairDiff) (prMatched paired)
++ concatMap addedEmitDiff (prAdded paired)
++ concatMap removedEmitDiff (prRemoved paired)
where
paired = pairByName nodeEmit emName env
emitPairDiff :: EmitNode -> EmitNode -> [Change]
emitPairDiff oldEmit newEmit =
[ breaking
(emName newEmit)
"derived-identity"
"messageId"
DerivedIdentityChanged
"messageId derive prefix changed; outbox retries no longer coalesce with persisted messages"
| emMessageId oldEmit /= emMessageId newEmit
]
++ [ breaking
(emName newEmit)
"derived-identity"
"idempotencyKey"
DerivedIdentityChanged
"idempotencyKey derive prefix changed; downstream dedupe no longer matches persisted messages"
| emIdempotencyKey oldEmit /= emIdempotencyKey newEmit
]
++ [ advisory
(emName newEmit)
"emit-mapping"
(emName newEmit)
EmitMappingChanged
"emit key, status discriminant, mapping rows, or explicit skip posture changed"
| emitMapping oldEmit /= emitMapping newEmit
]
emitMapping :: EmitNode -> (Name, Name, [EmitMapRow], Bool)
emitMapping emit = (emKey emit, emDiscriminant emit, emMap emit, emSkip emit)
addedEmitDiff :: EmitNode -> [Change]
addedEmitDiff emit = [additive (emName emit) "emit" (emName emit) "new emit mapping"]
removedEmitDiff :: EmitNode -> [Change]
removedEmitDiff emit = [breaking (emName emit) "derived-identity" (emName emit) DerivedIdentityChanged "emit removed while persisted outbox identities may still retry"]
publisherDiff :: DiffEnv -> [Change]
publisherDiff env =
concatMap (uncurry publisherPairDiff) (prMatched paired)
++ concatMap addedPublisherDiff (prAdded paired)
++ concatMap removedPublisherDiff (prRemoved paired)
where
paired = pairByName nodePublisher pubName env
publisherPairDiff :: PublisherNode -> PublisherNode -> [Change]
publisherPairDiff oldPublisher newPublisher =
-- maxAttempts/backoff are retry tuning, not persisted decode or identity.
[ breaking
(pubName newPublisher)
"derived-identity"
"outboxId"
DerivedIdentityChanged
"stable outbox-id source field changed; retries no longer coalesce with persisted outbox rows"
| pubOutboxField oldPublisher /= pubOutboxField newPublisher
]
++ [ advisory
(pubName newPublisher)
"publisher-policy"
(pubName newPublisher)
PublisherPolicyChanged
("ordering changed " <> pubOrdering oldPublisher <> " -> " <> pubOrdering newPublisher)
| pubOrdering oldPublisher /= pubOrdering newPublisher
]
addedPublisherDiff :: PublisherNode -> [Change]
addedPublisherDiff publisher = [additive (pubName publisher) "publisher" (pubName publisher) "new publisher"]
removedPublisherDiff :: PublisherNode -> [Change]
removedPublisherDiff publisher = [breaking (pubName publisher) "derived-identity" (pubName publisher) DerivedIdentityChanged "publisher removed while persisted outbox rows may still require its stable identity"]
pgmqDispatchDiff :: DiffEnv -> [Change]
pgmqDispatchDiff env =
concatMap (uncurry pgmqDispatchPairDiff) (prMatched paired)
++ concatMap addedPgmqDispatchDiff (prAdded paired)
++ concatMap removedPgmqDispatchDiff (prRemoved paired)
where
paired = pairByName nodePgmqDispatch pdName env
pgmqDispatchPairDiff :: PgmqDispatchNode -> PgmqDispatchNode -> [Change]
pgmqDispatchPairDiff oldDispatch newDispatch =
[ breaking
(pdName newDispatch)
"dedupe-identity"
(pdName newDispatch)
DedupeIdentityChanged
"dispatch dedupe key/read-model/queue surface changed; prior enqueue records no longer match"
| dispatchDedupe oldDispatch /= dispatchDedupe newDispatch
]
++ [ advisory
(pdName newDispatch)
"retarget"
(pdName newDispatch)
DispatchRetargeted
"source read model or target queue changed; future fan-out is routed differently"
| dispatchTargets oldDispatch /= dispatchTargets newDispatch
]
dispatchDedupe :: PgmqDispatchNode -> (Name, Name, Text, Name, Text)
dispatchDedupe dispatch =
( pdDedupKey dispatch
, pdDedupReadModel dispatch
, pdDedupReadModelField dispatch
, pdDedupQueue dispatch
, pdDedupQueueField dispatch
)
dispatchTargets :: PgmqDispatchNode -> (Name, Name)
dispatchTargets dispatch = (pdSourceReadModel dispatch, pdEnqueueTo dispatch)
addedPgmqDispatchDiff :: PgmqDispatchNode -> [Change]
addedPgmqDispatchDiff dispatch = [additive (pdName dispatch) "dispatch" (pdName dispatch) "new pgmq dispatch"]
removedPgmqDispatchDiff :: PgmqDispatchNode -> [Change]
removedPgmqDispatchDiff dispatch = [breaking (pdName dispatch) "dedupe-identity" (pdName dispatch) DedupeIdentityChanged "dispatch removed while persisted queue and read-model dedupe records may remain"]
{- | Classify the runtime's sanctioned workflow-evolution mechanisms before
falling back to the conservative unguarded-body rule.
-}
classifyWorkflowBody :: WorkflowNode -> WorkflowNode -> [Change]
classifyWorkflowBody oldWorkflow newWorkflow
| oldBody == newBody = []
| not (null removedPatchIds) = map removedPatch removedPatchIds
| Just (oldSeedType, newSeedType) <- changedSeed =
[ breaking nodeName "workflow-continue-as-new" nodeName WorkflowContinueSeedChanged $
"continueAsNew seed type changed " <> oldSeedType <> " -> " <> newSeedType <> "; the next generation's restoreSeed must decode the seed written by the previous generation"
]
| safeAdditions =
map addedPatch newPatchIds
++ [ additive nodeName "workflow-continue-as-new" seedType "terminal continueAsNew is additive; old generations carry no rotation marker"
| Just seedType <- [appendedSeed]
]
| otherwise =
[ breaking
nodeName
"workflow-body"
nodeName
WorkflowBodyChanged
"workflow body labels, kinds, result types, or order changed without a new patch guard; wrap a cross-cutting change in patch, or rename the replay label for one changed step"
]
where
nodeName = wfId newWorkflow
oldBody = normaliseWorkflowBody (wfBody oldWorkflow)
newBody = normaliseWorkflowBody (wfBody newWorkflow)
oldPatchIds = workflowBodyPatchIds oldBody
newPatchIdsAll = workflowBodyPatchIds newBody
newPatchIds = newPatchIdsAll \\ oldPatchIds
removedPatchIds = oldPatchIds \\ newPatchIdsAll
oldSeed = terminalContinueSeed oldBody
newSeed = terminalContinueSeed newBody
changedSeed = case (oldSeed, newSeed) of
(Just oldSeedType, Just newSeedType)
| oldSeedType /= newSeedType -> Just (oldSeedType, newSeedType)
_ -> Nothing
appendedSeed = case (oldSeed, newSeed) of
(Nothing, Just seedType) -> Just seedType
_ -> Nothing
strippedNewBody = stripNewPatches newPatchIds newBody
comparableNewBody = case appendedSeed of
Just _ -> dropTerminalContinue strippedNewBody
Nothing -> strippedNewBody
safeAdditions =
(not (null newPatchIds) || isJust appendedSeed)
&& comparableNewBody == oldBody
removedPatch patchId =
breaking nodeName "workflow-patch" patchId WorkflowPatchRemoved "patch id existed in the old spec but was removed; the differ cannot prove that no workflow generation still replays its journaled branch"
addedPatch patchId =
additive nodeName "workflow-patch" patchId "new patch guard contains the entire body change, so in-flight generations retain their journaled branch"
normaliseWorkflowBody :: [WfBodyItem] -> [WfBodyItem]
normaliseWorkflowBody = map go
where
go (WfStep label result _) = WfStep label result noLoc
go (WfAwait label result _) = WfAwait label result noLoc
go (WfSleep label delay _) = WfSleep label delay noLoc
go (WfChild label via result _) = WfChild label via result noLoc
go (WfPatch patchId items _) = WfPatch patchId (normaliseWorkflowBody items) noLoc
go (WfContinueAsNew seedType _) = WfContinueAsNew seedType noLoc
workflowBodyPatchIds :: [WfBodyItem] -> [Name]
workflowBodyPatchIds = concatMap go
where
go (WfPatch patchId items _) = patchId : workflowBodyPatchIds items
go _ = []
stripNewPatches :: [Name] -> [WfBodyItem] -> [WfBodyItem]
stripNewPatches newPatchIds = concatMap go
where
go (WfPatch patchId _ _) | patchId `elem` newPatchIds = []
go (WfPatch patchId items loc) = [WfPatch patchId (stripNewPatches newPatchIds items) loc]
go item = [item]
terminalContinueSeed :: [WfBodyItem] -> Maybe Name
terminalContinueSeed items = case reverse items of
WfContinueAsNew seedType _ : _ -> Just seedType
_ -> Nothing
dropTerminalContinue :: [WfBodyItem] -> [WfBodyItem]
dropTerminalContinue items = case reverse items of
WfContinueAsNew{} : rest -> reverse rest
_ -> items
additive :: Name -> Text -> Text -> Text -> Change
additive n facet subj detail = Additive (ChangeKind n facet subj Nothing detail)
breaking :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change
breaking n facet subj c detail = Breaking (ChangeKind n facet subj (Just c) detail)
advisory :: Name -> Text -> Text -> DiagnosticCode -> Text -> Change
advisory n facet subj c detail = Advisory (ChangeKind n facet subj (Just c) detail)
commas :: [Text] -> Text
commas = T.intercalate ", "
tInt :: Int -> Text
tInt = T.pack . show