packages feed

keiro-dsl-0.12.0.0: src/Keiro/Dsl/ScaffoldRecord.hs

-- | Versioned persistence for the files and mapped consumer identities used by
-- one successful scaffold run. Unknown header fields are ignored so v1 readers
-- can consume records extended by later tool versions. Mapping rows are canonical
-- single-line JSON after a @mapping @ prefix; old readers ignore that row kind.
module Keiro.Dsl.ScaffoldRecord
  ( ScaffoldRecord (..),
    ScaffoldModuleRoleRow (..),
    GeneratedHaskellNamingEdition (..),
    renderRecord,
    parseRecord,
    recordFileName,
    projectionCatalogFacts,
    projectionCatalogFactsForService,
  )
where

import Data.Aeson ((.:), (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as BL
import Data.List (nub, sort)
import Data.List.NonEmpty qualified as NE
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as Text
import Keiro.Dsl.BehaviorCoverage (BehaviorRecordRow (..))
import Keiro.Dsl.CoordinationImpact (RouterSelectionSnapshot (..))
import Keiro.Dsl.ExplainBindings (BindingHole (..))
import Keiro.Dsl.Grammar
import Keiro.Dsl.HaskellName (GeneratedHaskellNamingEdition (..), parseGeneratedHaskellNamingEdition, renderGeneratedHaskellNamingEdition)
import Keiro.Dsl.LanguageVersion (SourceLanguage (..))
import Keiro.Dsl.MappedConsumer (MappingIdentity (..))
import Keiro.Dsl.ProjectionSupply
import Keiro.Dsl.ReadModelQueryContract (QueryContractIdentity, queryContractIdentityKey)
import Keiro.Dsl.Scaffold (ModuleKind (..), ModuleRole (..))
import Keiro.Dsl.SemanticContract (CheckedService, EffectiveLanguageContract, checkedProjectionSupplies, checkedSpec, effectiveLanguageContract)
import Keiro.Dsl.SemanticImpact (SemanticImpactSnapshot)
import Keiro.Dsl.SidecarNames (contextLedgerFileName)
import System.FilePath (isAbsolute, splitDirectories)

data ScaffoldRecord = ScaffoldRecord
  { recSpecPath :: !Text,
    recModuleRoot :: !Text,
    recLayout :: !Text,
    recSourceLanguage :: !SourceLanguage,
    recLanguageContract :: !EffectiveLanguageContract,
    recNamingEdition :: !GeneratedHaskellNamingEdition,
    recModuleRoles :: ![ScaffoldModuleRoleRow],
    recFiles :: ![(ModuleKind, FilePath)],
    recMappings :: ![MappingIdentity],
    recIdDomains :: ![Text],
    recNominalEqualities :: ![Text],
    recBindingObligations :: ![BindingHole],
    recBehaviorRequirements :: ![BehaviorRecordRow],
    recProjectionCatalogFacts :: ![Text],
    recQueryContractBaseline :: !Bool,
    recQueryContracts :: ![QueryContractIdentity],
    recRouterSelections :: ![RouterSelectionSnapshot],
    recSemanticImpact :: !(Maybe SemanticImpactSnapshot)
  }
  deriving stock (Eq, Show)

data ScaffoldModuleRoleRow = ScaffoldModuleRoleRow
  { srrRole :: !ModuleRole,
    srrKind :: !ModuleKind,
    srrPath :: !FilePath
  }
  deriving stock (Eq, Show)

instance Aeson.ToJSON ScaffoldModuleRoleRow where
  toJSON row =
    Aeson.object
      [ "ownerKind" .= roleOwnerKind role,
        "ownerName" .= roleOwnerName role,
        "family" .= roleFamily role,
        "kind" .= (case srrKind row of Generated -> "generated" :: Text; HoleStub -> "hole"),
        "path" .= T.pack (srrPath row)
      ]
    where
      role = srrRole row

instance Aeson.FromJSON ScaffoldModuleRoleRow where
  parseJSON = Aeson.withObject "ScaffoldModuleRoleRow" $ \fields -> do
    ownerKind <- fields .: "ownerKind"
    ownerName <- fields .: "ownerName"
    family <- fields .: "family"
    kindLabel <- fields .: "kind"
    rowKind <- case (kindLabel :: Text) of
      "generated" -> pure Generated
      "hole" -> pure HoleStub
      other -> fail ("unknown module kind: " <> T.unpack other)
    rowPath <- fields .: "path"
    pure
      ScaffoldModuleRoleRow
        { srrRole = ModuleRole ownerKind ownerName family,
          srrKind = rowKind,
          srrPath = T.unpack (rowPath :: Text)
        }

renderRecord :: ScaffoldRecord -> Text
renderRecord record =
  T.unlines $
    [ "keiro-dsl scaffold record v1",
      "spec: " <> recSpecPath record,
      "module-root: " <> rootLabel,
      "layout: " <> recLayout record,
      "source-language " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode (recSourceLanguage record))),
      "semantic-contract " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode (recLanguageContract record))),
      "naming-edition " <> renderGeneratedHaskellNamingEdition (recNamingEdition record)
    ]
      <> map ("module-role " <>) (map (Text.decodeUtf8 . BL.toStrict . Aeson.encode) (recModuleRoles record))
      <> map renderFile (recFiles record)
      <> map renderMapping (recMappings record)
      <> map ("id-domain " <>) (recIdDomains record)
      <> map ("nominal-equality " <>) (recNominalEqualities record)
      <> map renderBindingObligation (recBindingObligations record)
      <> map renderBehaviorRequirement (recBehaviorRequirements record)
      <> map ("projection-catalog-fact " <>) (recProjectionCatalogFacts record)
      <> ["query-contract-baseline v1" | recQueryContractBaseline record]
      <> map ("query-contract " <>) (map (Text.decodeUtf8 . BL.toStrict . Aeson.encode) (recQueryContracts record))
      <> map ("router-selection " <>) (map (Text.decodeUtf8 . BL.toStrict . Aeson.encode) (recRouterSelections record))
      <> ["semantic-impact " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode snapshot)) | Just snapshot <- [recSemanticImpact record]]
  where
    rootLabel = if T.null (recModuleRoot record) then "(none)" else recModuleRoot record
    renderFile (Generated, path) = "generated " <> T.pack path
    renderFile (HoleStub, path) = "hole " <> T.pack path
    renderMapping mapping =
      mappingRowPrefix mapping <> Text.decodeUtf8 (BL.toStrict (Aeson.encode mapping))
    renderBindingObligation obligation =
      "binding " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode obligation))
    renderBehaviorRequirement requirement =
      "behavior " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode requirement))

-- | Parse a v1 record. The version header and the three required fields must
-- be present exactly once. Unknown lines are ignored for forward compatibility;
-- unsafe file paths are rejected rather than joined to a scaffold output root.
parseRecord :: Text -> Maybe ScaffoldRecord
parseRecord contents = case T.lines contents of
  header : rows
    | header == "keiro-dsl scaffold record v1" -> do
        specPath <- exactlyOne "spec: " rows
        rootLabel <- exactlyOne "module-root: " rows
        layout <- exactlyOne "layout: " rows
        sourceLanguage <- parseSourceLanguage rows
        languageContract <- parseLanguageContract sourceLanguage rows
        namingEdition <- parseNamingEdition rows
        moduleRoles <- traverse parseModuleRole (filter ("module-role " `T.isPrefixOf`) rows)
        files <- traverse parseFile (filter isFileRow rows)
        ordinaryMappings <- traverse (parseMapping "mapping ") (filter ("mapping " `T.isPrefixOf`) rows)
        nominalMappings <- traverse (parseMapping "nominal-mapping ") (filter ("nominal-mapping " `T.isPrefixOf`) rows)
        let mappings = ordinaryMappings <> nominalMappings
        let idDomains = [identity | row <- rows, Just identity <- [T.stripPrefix "id-domain " row]]
        let nominalEqualities = [identity | row <- rows, Just identity <- [T.stripPrefix "nominal-equality " row]]
        bindingEntries <- traverse parseBindingObligation (filter ("binding " `T.isPrefixOf`) rows)
        behaviorEntries <- traverse parseBehaviorRequirement (filter ("behavior " `T.isPrefixOf`) rows)
        let catalogFacts = [fact | row <- rows, Just fact <- [T.stripPrefix "projection-catalog-fact " row]]
        queryContractBaseline <- parseQueryContractBaseline rows
        queryContracts <- traverse parseQueryContract (filter ("query-contract " `T.isPrefixOf`) rows)
        routerSelections <- traverse parseRouterSelection (filter ("router-selection " `T.isPrefixOf`) rows)
        semanticImpact <- parseSemanticImpact rows
        if hasDuplicateMappingNames mappings || hasDuplicates idDomains || hasDuplicates nominalEqualities || hasDuplicateBindingObligations bindingEntries || hasDuplicateBehaviorRequirements behaviorEntries || hasDuplicates catalogFacts || hasDuplicates (map queryContractIdentityKey queryContracts) || hasDuplicates (map selectionRouter routerSelections)
          then Nothing
          else
            pure
              ScaffoldRecord
                { recSpecPath = specPath,
                  recModuleRoot = if rootLabel == "(none)" then "" else rootLabel,
                  recLayout = layout,
                  recSourceLanguage = sourceLanguage,
                  recLanguageContract = languageContract,
                  recNamingEdition = namingEdition,
                  recModuleRoles = moduleRoles,
                  recFiles = files,
                  recMappings = mappings,
                  recIdDomains = idDomains,
                  recNominalEqualities = nominalEqualities,
                  recBindingObligations = bindingEntries,
                  recBehaviorRequirements = behaviorEntries,
                  recProjectionCatalogFacts = catalogFacts,
                  recQueryContractBaseline = queryContractBaseline,
                  recQueryContracts = queryContracts,
                  recRouterSelections = routerSelections,
                  recSemanticImpact = semanticImpact
                }
  _ -> Nothing
  where
    exactlyOne prefix rows = case [value | row <- rows, Just value <- [T.stripPrefix prefix row]] of
      [value] -> Just value
      _ -> Nothing
    isFileRow row = "generated " `T.isPrefixOf` row || "hole " `T.isPrefixOf` row
    parseFile row
      | Just path <- T.stripPrefix "generated " row = checkedFile Generated path
      | Just path <- T.stripPrefix "hole " row = checkedFile HoleStub path
      | otherwise = Nothing
    checkedFile fileKind pathText =
      let path = T.unpack pathText
       in if null path || isAbsolute path || ".." `elem` splitDirectories path
            then Nothing
            else Just (fileKind, path)
    parseMapping prefix row = do
      payload <- T.stripPrefix prefix row
      Aeson.decodeStrict' (Text.encodeUtf8 payload)
    parseBindingObligation row = do
      payload <- T.stripPrefix "binding " row
      Aeson.decodeStrict' (Text.encodeUtf8 payload)
    parseBehaviorRequirement row = do
      payload <- T.stripPrefix "behavior " row
      Aeson.decodeStrict' (Text.encodeUtf8 payload)
    parseQueryContract row = do
      payload <- T.stripPrefix "query-contract " row
      Aeson.decodeStrict' (Text.encodeUtf8 payload)
    parseRouterSelection row = do
      payload <- T.stripPrefix "router-selection " row
      Aeson.decodeStrict' (Text.encodeUtf8 payload)
    parseQueryContractBaseline rows = case filter ("query-contract-baseline " `T.isPrefixOf`) rows of
      [] -> Just False
      ["query-contract-baseline v1"] -> Just True
      _ -> Nothing
    parseSemanticImpact rows = case filter ("semantic-impact " `T.isPrefixOf`) rows of
      [] -> Just Nothing
      [row] -> do
        payload <- T.stripPrefix "semantic-impact " row
        Just <$> Aeson.decodeStrict' (Text.encodeUtf8 payload)
      _ -> Nothing
    parseModuleRole row = do
      payload <- T.stripPrefix "module-role " row
      decoded <- Aeson.decodeStrict' (Text.encodeUtf8 payload)
      checkedRole decoded
    checkedRole roleRow = do
      path <- checkedPath (T.pack (srrPath roleRow))
      pure roleRow {srrPath = path}
    checkedPath pathText =
      let path = T.unpack pathText
       in if null path || isAbsolute path || ".." `elem` splitDirectories path
            then Nothing
            else Just path
    parseSourceLanguage rows = case filter ("source-language " `T.isPrefixOf`) rows of
      [] -> Just LegacyUnversioned
      [row] -> do
        payload <- T.stripPrefix "source-language " row
        Aeson.decodeStrict' (Text.encodeUtf8 payload)
      _ -> Nothing
    parseLanguageContract sourceLanguage rows = case filter ("semantic-contract " `T.isPrefixOf`) rows of
      [] -> Just (effectiveLanguageContract sourceLanguage)
      [row] -> do
        payload <- T.stripPrefix "semantic-contract " row
        contract <- Aeson.decodeStrict' (Text.encodeUtf8 payload)
        if contract == effectiveLanguageContract sourceLanguage then Just contract else Nothing
      _ -> Nothing
    parseNamingEdition rows = case filter ("naming-edition " `T.isPrefixOf`) rows of
      [] -> Just LegacyNamingV1
      [row] -> T.stripPrefix "naming-edition " row >>= parseGeneratedHaskellNamingEdition
      _ -> Nothing
    hasDuplicateMappingNames mappings =
      let names = map mappingSpecName mappings
       in length names /= length (nub names)
    hasDuplicates values = length values /= length (nub values)
    hasDuplicateBindingObligations obligations =
      let keys = map bindingKey obligations
       in length keys /= length (nub keys)
    bindingKey hole =
      ( holeMappedName hole,
        holeModule hole,
        holeSymbol hole,
        holeKind hole,
        holePath hole
      )
    hasDuplicateBehaviorRequirements requirements =
      let keys = map behaviorRecordKey requirements
       in length keys /= length (nub keys)

recordFileName :: Text -> FilePath
recordFileName = contextLedgerFileName

mappingRowPrefix :: MappingIdentity -> Text
mappingRowPrefix NominalMapping {} = "nominal-mapping "
mappingRowPrefix _ = "mapping "

-- | Canonical durable catalog identities used when a declaration disappears
-- from the next graph. Source lines remain part of the attribution evidence.
projectionCatalogFacts :: Spec -> [Text]
projectionCatalogFacts spec = projectionCatalogFactsWith spec (analyzeProjectionSupplies spec)

projectionCatalogFactsForService :: CheckedService -> [Text]
projectionCatalogFactsForService service =
  projectionCatalogFactsWith (checkedSpec service) (checkedProjectionSupplies service)

projectionCatalogFactsWith :: Spec -> ProjectionSupplyAnalysis -> [Text]
projectionCatalogFactsWith spec supplyAnalysis = sort (concatMap nodeFacts (specNodes spec) <> map supplyFact supplies)
  where
    supplies = resolvedProjectionSupplies supplyAnalysis
    owners = [owner | NProjectionOwner owner <- specNodes spec]
    nodeFacts (NProjectionTarget target) =
      [T.intercalate "|" ["target", ptName target, ptSchema target, ptTable target, resetText (ptReset target), T.intercalate "," (ptDependsOn target), lineText (ptLoc target)]]
    nodeFacts (NRebuildGroup groupNode) =
      [T.intercalate "|" ["group", rgName groupNode, T.intercalate "," (sort (rgTargets groupNode)), T.intercalate "," (rgOrder groupNode), lineText (rgLoc groupNode)]]
    nodeFacts (NProjectionRevision revision) =
      [ T.intercalate
          "|"
          [ "revision",
            prvName revision,
            prvGroup revision,
            T.intercalate ";" (map revisionTargetText (prvTargets revision)),
            lineText (prvLoc revision)
          ]
      ]
    nodeFacts (NExternalRead externalRead) =
      [ T.intercalate
          "|"
          [ "external-read",
            erName externalRead,
            T.pack (show (erVersion externalRead)),
            erQueryModel externalRead,
            erResultSchema externalRead <> "." <> erResultType externalRead,
            externalReadShape externalRead,
            T.intercalate "," (sort (erCompatibleRevisions externalRead)),
            T.pack (show (erSurfaceGeneration externalRead)),
            lineText (erLoc externalRead)
          ]
      ]
    nodeFacts (NProjectionOwner owner) =
      [ T.intercalate
          "|"
          [ "owner",
            poName owner,
            T.intercalate "," (map sourceText (poSources owner)),
            poGroup owner,
            T.intercalate "," (sort (poTargets owner)),
            T.pack (show (poOrder owner)),
            maybe "" id (poSubscription owner),
            maybe "" id (poDedup owner),
            T.intercalate "," (map checkpointOnMissingText (poCheckpointOnMissing owner)),
            replayText (poReplay owner),
            lineText (poLoc owner)
          ],
        T.intercalate
          "|"
          [ "delivery",
            poName owner,
            deliveryText (poDelivery owner),
            lineText (poLoc owner)
          ]
      ]
    nodeFacts (NReadModel readModel)
      | Just groupName <- rmGroup readModel =
          [ T.intercalate
              "|"
              [ "query",
                rmName readModel,
                groupName,
                T.intercalate "," (sort (rmObservedTargets readModel)),
                fromMaybe "" (effectiveBacking readModel),
                lineText (rmLoc readModel)
              ],
            T.intercalate
              "|"
              [ "freshness",
                rmName readModel,
                freshnessText (rmFreshness readModel),
                lineText (rmLoc readModel)
              ],
            T.intercalate
              "|"
              [ "cursor",
                rmName readModel,
                fromMaybe "none" (resolvedCursor readModel),
                lineText (rmLoc readModel)
              ]
          ]
    nodeFacts _ = []
    externalReadShape externalRead = case [rmShape readModel | NReadModel readModel <- specNodes spec, rmName readModel == erQueryModel externalRead] of
      shape : _ -> shape
      [] -> "missing-query"
    revisionTargetText target =
      T.intercalate
        ","
        [ prtTarget target,
          prtSchemaVersion target,
          prtProvisioner target,
          T.pack (show (prtProvisionerVersion target)),
          prtExpectedShape target,
          prtValidator target,
          T.pack (show (prtValidatorVersion target)),
          T.intercalate ":" (map promotionText (prtPromotionObjects target))
        ]
    promotionText promotionObject =
      T.intercalate
        ">"
        [ promotionKindText (rpoKind promotionObject),
          rpoGenerationName promotionObject,
          rpoCanonicalName promotionObject
        ]
    promotionKindText PromotionIndexNode = "index"
    promotionKindText PromotionConstraintNode = "constraint"
    promotionKindText PromotionOwnedSequenceNode = "owned-sequence"
    supplyFact supply =
      T.intercalate
        "|"
        [ "supply",
          supplyQueryModel supply,
          supplyProjectionOwner supply,
          supplyRebuildGroup supply,
          T.intercalate "," (NE.toList (supplyObservedTargets supply)),
          lineText (supplyQueryLoc supply),
          lineText (supplyOwnerLoc supply)
        ]
    effectiveBacking readModel = case rmBackingTarget readModel of
      Just targetName -> Just targetName
      Nothing -> case sort (rmObservedTargets readModel) of
        [targetName] -> Just targetName
        _ -> Nothing
    resetText TargetClear = "clear"
    resetText TargetPreserve = "preserve"
    sourceText CatalogAll = "all"
    sourceText (CatalogCategory categoryName) = "category:" <> categoryName
    sourceText (CatalogAggregate aggregateName) = "aggregate:" <> aggregateName
    deliveryText DeliveryInline = "inline"
    deliveryText DeliverySubscription = "subscription"
    freshnessText FreshnessImmediate = "immediate"
    freshnessText (FreshnessWaitForHead RmEntireLog) = "wait-for-head:entire-log"
    freshnessText (FreshnessWaitForHead (RmCategory categoryName)) = "wait-for-head:category:" <> categoryName
    resolvedCursor readModel = do
      ownerName <- case [ supplyProjectionOwner supply
                        | supply <- supplies,
                          supplyQueryModel supply == rmName readModel
                        ] of
        [name] -> Just name
        _ -> Nothing
      owner <- case [candidate | candidate <- owners, poName candidate == ownerName] of
        [candidate] -> Just candidate
        _ -> Nothing
      case poDelivery owner of
        DeliveryInline -> Nothing
        DeliverySubscription -> poSubscription owner
    checkpointOnMissingText CheckpointFromBeginning = "from-beginning"
    checkpointOnMissingText CheckpointFromCurrentHead = "from-current-head"
    checkpointOnMissingText CheckpointFail = "fail"
    replayText ProjectionReplayExplicit = "explicit"
    replayText (ProjectionLiveOnly reason) = "live-only:" <> reason
    lineText (Loc lineNumber) = T.pack (show lineNumber)