keiro-dsl-0.12.0.0: src/Keiro/Dsl/ProjectionMappedImpact.hs
{-# OPTIONS_GHC -Werror=incomplete-patterns #-}
-- | Projection dependencies inherited from authoritative aggregate event
-- roots. This module never invents a projection type expression: it projects
-- the checked mapped-event graph onto the existing inline/catalog ownership
-- graph and keeps SQL effects explicitly operational rather than typed.
module Keiro.Dsl.ProjectionMappedImpact
( ProjectionMappedRoot (..),
ProjectionOperationalImpact (..),
UnsupportedProjectionImpact (..),
ProjectionMappedImpact (..),
projectionMappedImpact,
projectionMappedImpactForService,
projectionConsumersFor,
projectionOperationsFor,
projectionAggregateSourceFingerprint,
projectionAggregateSourceFingerprintForService,
renderProjectionMappedImpact,
)
where
import Data.List (find, nub, sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Keiro.Dsl.Grammar
import Keiro.Dsl.ReadModelShape (fnv1a64)
import Keiro.Dsl.SemanticContract (CheckedService, checkedSpec, checkedTypeGraph)
import Keiro.Dsl.SemanticImpact
import Keiro.Dsl.TypeGraph
-- | One complete inherited event path for one derived projection consumer and
-- one declaration in the event root's transitive mapped closure.
data ProjectionMappedRoot = ProjectionMappedRoot
{ consumer :: !DerivedMappedConsumer,
declaration :: !MappedKey,
path :: !UsePath
}
deriving stock (Eq, Ord, Show, Generic)
-- | Operational effects related to a typed projection consumer. Targets and
-- observing query models do not become Haskell type consumers: unrestricted
-- handler SQL prevents that stronger claim.
data ProjectionOperationalImpact = ProjectionOperationalImpact
{ consumer :: !DerivedMappedConsumer,
group :: !(Maybe Name),
targets :: !(Set Name),
readModels :: !(Set Name),
replayable :: !Bool,
sourceFingerprint :: !Text
}
deriving stock (Eq, Ord, Show, Generic)
-- | A category/all-history owner participates in catalog operations but has
-- no single mapped event type. Keeping the whole operational relation makes
-- that unsupported typed boundary visible without attaching a fake key.
data UnsupportedProjectionImpact = UnsupportedProjectionImpact
{ source :: !UnsupportedProjectionSource,
group :: !Name,
targets :: !(Set Name),
readModels :: !(Set Name),
replayable :: !Bool
}
deriving stock (Eq, Ord, Show, Generic)
data ProjectionMappedImpact = ProjectionMappedImpact
{ roots :: ![ProjectionMappedRoot],
consumers :: !(Map MappedKey (Set DerivedMappedConsumer)),
operations :: !(Map DerivedMappedConsumer ProjectionOperationalImpact),
unsupported :: ![UnsupportedProjectionImpact]
}
deriving stock (Eq, Show, Generic)
-- | Join checked mapped-event paths to inline/catalog projection ownership.
-- Command, register, queue, and query roots are excluded by construction.
projectionMappedImpact :: CheckedService -> SemanticImpact -> ProjectionMappedImpact
projectionMappedImpact service semantic =
ProjectionMappedImpact
{ roots = mappedRoots,
consumers =
Map.fromListWith
Set.union
[ (declarationKey, Set.singleton derived)
| ProjectionMappedRoot derived declarationKey _ <- mappedRoots
],
operations = Map.fromList [(derived, operation) | operation@(ProjectionOperationalImpact derived _ _ _ _ _) <- operational],
unsupported = sort (mapMaybeUnsupported unsupportedSources)
}
where
spec = checkedSpec service
graph = either (const Nothing) Just (checkedTypeGraph service)
derivedConsumers =
Set.fromList
[ derived
| mappedConsumers <- Map.elems (impactDeclarationConsumers semantic),
DerivedProjectionConsumer derived <- Set.toList mappedConsumers
]
mappedRoots =
sort . nub $
[ ProjectionMappedRoot derived declarationKey usePath
| (declarationKey, usePathValues) <- Map.toAscList (impactUsePaths semantic),
usePath <- usePathValues,
aggregate <- maybeToList (eventAuthority usePath),
derived <- Set.toAscList derivedConsumers,
derivedAuthority derived == aggregate
]
operational =
mapMaybeOperation
(Set.toAscList (Set.fromList [derived | ProjectionMappedRoot derived _ _ <- mappedRoots]))
unsupportedSources = impactUnsupportedProjectionSources semantic
mapMaybeOperation = foldr (maybe id (:) . operationFor graph spec) []
mapMaybeUnsupported = foldr (maybe id (:) . unsupportedFor spec) []
-- | Resolve and project a checked service without making callers reconstruct
-- the shared type graph. A failed resolution remains explicit even though the
-- scaffold admission gate normally prevents it from reaching report creation.
projectionMappedImpactForService :: CheckedService -> Maybe ProjectionMappedImpact
projectionMappedImpactForService service = case checkedTypeGraph service of
Left _ -> Nothing
Right graph -> Just (projectionMappedImpact service (semanticImpact graph))
projectionConsumersFor :: ProjectionMappedImpact -> MappedKey -> Set DerivedMappedConsumer
projectionConsumersFor impact declarationKey = Map.findWithDefault Set.empty declarationKey (consumers impact)
projectionOperationsFor :: ProjectionMappedImpact -> MappedKey -> [ProjectionOperationalImpact]
projectionOperationsFor impact declarationKey =
[ operation
| derived <- Set.toAscList (projectionConsumersFor impact declarationKey),
operation <- maybeToList (Map.lookup derived (operations impact))
]
-- | Stable source metadata for generated aggregate codecs. Existing aggregate
-- sources with no mapped event roots keep their historical byte exactly. A
-- mapped event root adds a digest over its complete root spelling and
-- transitive wire authority; command/register/query-only mappings are absent.
projectionAggregateSourceFingerprint :: Spec -> Name -> Text
projectionAggregateSourceFingerprint spec aggregate =
projectionAggregateSourceFingerprintWithGraph
(either (const Nothing) Just (resolveTypeGraph spec))
aggregate
projectionAggregateSourceFingerprintForService :: CheckedService -> Name -> Text
projectionAggregateSourceFingerprintForService service =
projectionAggregateSourceFingerprintWithGraph
(either (const Nothing) Just (checkedTypeGraph service))
projectionAggregateSourceFingerprintWithGraph :: Maybe TypeGraph -> Name -> Text
projectionAggregateSourceFingerprintWithGraph maybeGraph aggregate =
case maybeGraph of
Nothing -> base
Just graph -> case eventRows graph of
[] -> base
rows -> base <> "/mapped-" <> fnv1a64 (T.intercalate "\n" rows)
where
base = "aggregate:" <> aggregate <> "/generated-codec/v1"
eventRows graph =
sort
[ renderUsePath (UsePath site (useSiteSegments graph site))
<> "|wire="
<> wireFingerprint graph (unMappedKey declarationKey)
| site@(RootEventField authority _ _ declarationKey) <- tgUseSites graph,
authority == aggregate
]
-- | Human-readable typed and operational projection evidence. Complete event
-- roots are shown independently from groups, targets, observing read models,
-- replay policy, and source fingerprints so SQL is never presented as a type
-- dependency. Category/all boundaries remain explicit and untyped.
renderProjectionMappedImpact :: ProjectionMappedImpact -> [Text]
renderProjectionMappedImpact impact
| Map.null (consumers impact) && null (unsupported impact) = []
| otherwise =
["projection mapped impact:"]
<> concatMap renderDeclaration (Map.toAscList (consumers impact))
<> renderUnsupported (unsupported impact)
where
renderDeclaration (declarationKey, derivedConsumers) =
[" " <> unMappedKey declarationKey]
<> concatMap (renderConsumer declarationKey) (Set.toAscList derivedConsumers)
renderConsumer declarationKey derived =
[ " " <> mappedConsumerIdentity (DerivedProjectionConsumer derived),
" inherited event roots: " <> renderSet (Set.fromList (pathsFor declarationKey derived))
]
<> maybe [] (pure . (" operation: " <>) . renderOperation) (Map.lookup derived (operations impact))
pathsFor declarationKey derived =
[ renderUsePath inheritedPath
| ProjectionMappedRoot candidate declaration inheritedPath <- roots impact,
candidate == derived,
declaration == declarationKey
]
renderOperation (ProjectionOperationalImpact _ groupName targetNames observerNames canReplay fingerprint) =
"group="
<> maybe "(inline)" id groupName
<> "; targets="
<> renderSet targetNames
<> "; read-models="
<> renderSet observerNames
<> "; replayable="
<> yesNo canReplay
<> "; source-fingerprint="
<> fingerprint
renderUnsupported [] = []
renderUnsupported boundaries =
[" unsupported typed sources:"] <> concatMap renderBoundary boundaries
renderBoundary (UnsupportedProjectionImpact boundary groupName targetNames observerNames canReplay) =
[ " " <> renderBoundaryName boundary,
" operation: group="
<> groupName
<> "; targets="
<> renderSet targetNames
<> "; read-models="
<> renderSet observerNames
<> "; replayable="
<> yesNo canReplay
<> "; mapped-key=(unsupported heterogeneous source)"
]
renderBoundaryName (UnsupportedCatalogCategory owner categoryName) =
"catalog-category:" <> owner <> ":" <> categoryName
renderBoundaryName (UnsupportedCatalogAll owner) = "catalog-all:" <> owner
renderSet values = case Set.toAscList values of
[] -> "(none)"
names -> T.intercalate ", " names
yesNo True = "yes"
yesNo False = "no"
operationFor :: Maybe TypeGraph -> Spec -> DerivedMappedConsumer -> Maybe ProjectionOperationalImpact
operationFor graph spec derived = case derived of
AggregateInlineProjectionConsumer aggregate projection ->
Just
ProjectionOperationalImpact
{ consumer = derived,
group = Nothing,
targets = Set.singleton projection,
readModels = Set.fromList [rmName readModel | readModel <- readModelNodes spec, rmName readModel == projection],
replayable = False,
sourceFingerprint = projectionAggregateSourceFingerprintWithGraph graph aggregate
}
CatalogProjectionConsumer ownerName aggregate -> do
owner <- find ((== ownerName) . poName) (projectionOwners spec)
pure
ProjectionOperationalImpact
{ consumer = derived,
group = Just (poGroup owner),
targets = Set.fromList (poTargets owner),
readModels = observingReadModels spec owner,
replayable = isReplayable owner,
sourceFingerprint = projectionAggregateSourceFingerprintWithGraph graph aggregate
}
unsupportedFor :: Spec -> UnsupportedProjectionSource -> Maybe UnsupportedProjectionImpact
unsupportedFor spec boundary = do
owner <- find ((== unsupportedOwner boundary) . poName) (projectionOwners spec)
pure
UnsupportedProjectionImpact
{ source = boundary,
group = poGroup owner,
targets = Set.fromList (poTargets owner),
readModels = observingReadModels spec owner,
replayable = isReplayable owner
}
observingReadModels :: Spec -> ProjectionOwnerNode -> Set Name
observingReadModels spec owner =
Set.fromList
[ rmName readModel
| readModel <- readModelNodes spec,
rmGroup readModel == Just (poGroup owner),
not (Set.disjoint (Set.fromList (rmObservedTargets readModel)) (Set.fromList (poTargets owner)))
]
eventAuthority :: UsePath -> Maybe Name
eventAuthority UsePath {upRoot = RootEventField aggregate _ _ _} = Just aggregate
eventAuthority _ = Nothing
derivedAuthority :: DerivedMappedConsumer -> Name
derivedAuthority (AggregateInlineProjectionConsumer aggregate _) = aggregate
derivedAuthority (CatalogProjectionConsumer _ aggregate) = aggregate
unsupportedOwner :: UnsupportedProjectionSource -> Name
unsupportedOwner (UnsupportedCatalogCategory owner _) = owner
unsupportedOwner (UnsupportedCatalogAll owner) = owner
projectionOwners :: Spec -> [ProjectionOwnerNode]
projectionOwners spec = [owner | NProjectionOwner owner <- specNodes spec]
readModelNodes :: Spec -> [ReadModelNode]
readModelNodes spec = [readModel | NReadModel readModel <- specNodes spec]
isReplayable :: ProjectionOwnerNode -> Bool
isReplayable owner = case poReplay owner of
ProjectionReplayExplicit -> True
ProjectionLiveOnly _ -> False
maybeToList :: Maybe value -> [value]
maybeToList = maybe [] pure