packages feed

keiro-dsl-0.15.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
  { specPath :: !Text,
    moduleRoot :: !Text,
    layout :: !Text,
    sourceLanguage :: !SourceLanguage,
    languageContract :: !EffectiveLanguageContract,
    namingEdition :: !GeneratedHaskellNamingEdition,
    moduleRoles :: ![ScaffoldModuleRoleRow],
    files :: ![(ModuleKind, FilePath)],
    mappings :: ![MappingIdentity],
    idDomains :: ![Text],
    nominalEqualities :: ![Text],
    bindingObligations :: ![BindingHole],
    behaviorRequirements :: ![BehaviorRecordRow],
    projectionCatalogFacts :: ![Text],
    queryContractBaseline :: !Bool,
    queryContracts :: ![QueryContractIdentity],
    routerSelections :: ![RouterSelectionSnapshot],
    semanticImpact :: !(Maybe SemanticImpactSnapshot)
  }
  deriving stock (Eq, Show)

data ScaffoldModuleRoleRow = ScaffoldModuleRoleRow
  { role :: !ModuleRole,
    kind :: !ModuleKind,
    path :: !FilePath
  }
  deriving stock (Eq, Show)

instance Aeson.ToJSON ScaffoldModuleRoleRow where
  toJSON row =
    Aeson.object
      [ "ownerKind" .= (.ownerKind) role,
        "ownerName" .= (.ownerName) role,
        "family" .= (.family) role,
        "kind" .= (case (.kind) row of Generated -> "generated" :: Text; HoleStub -> "hole"),
        "path" .= T.pack ((.path) row)
      ]
    where
      role = (.role) 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
        { role = ModuleRole ownerKind ownerName family,
          kind = rowKind,
          path = T.unpack (rowPath :: Text)
        }

renderRecord :: ScaffoldRecord -> Text
renderRecord record =
  T.unlines $
    [ "keiro-dsl scaffold record v1",
      "spec: " <> (.specPath) record,
      "module-root: " <> rootLabel,
      "layout: " <> (.layout) record,
      "source-language " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode ((.sourceLanguage) record))),
      "semantic-contract " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode ((.languageContract) record))),
      "naming-edition " <> renderGeneratedHaskellNamingEdition ((.namingEdition) record)
    ]
      <> map ("module-role " <>) (map (Text.decodeUtf8 . BL.toStrict . Aeson.encode) ((.moduleRoles) record))
      <> map renderFile ((.files) record)
      <> map renderMapping ((.mappings) record)
      <> map ("id-domain " <>) ((.idDomains) record)
      <> map ("nominal-equality " <>) ((.nominalEqualities) record)
      <> map renderBindingObligation ((.bindingObligations) record)
      <> map renderBehaviorRequirement ((.behaviorRequirements) record)
      <> map ("projection-catalog-fact " <>) ((.projectionCatalogFacts) record)
      <> ["query-contract-baseline v1" | (.queryContractBaseline) record]
      <> map ("query-contract " <>) (map (Text.decodeUtf8 . BL.toStrict . Aeson.encode) ((.queryContracts) record))
      <> map ("router-selection " <>) (map (Text.decodeUtf8 . BL.toStrict . Aeson.encode) ((.routerSelections) record))
      <> ["semantic-impact " <> Text.decodeUtf8 (BL.toStrict (Aeson.encode snapshot)) | Just snapshot <- [(.semanticImpact) record]]
  where
    rootLabel = if T.null ((.moduleRoot) record) then "(none)" else (.moduleRoot) 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 (.router) routerSelections)
          then Nothing
          else
            pure
              ScaffoldRecord
                { specPath = specPath,
                  moduleRoot = if rootLabel == "(none)" then "" else rootLabel,
                  layout = layout,
                  sourceLanguage = sourceLanguage,
                  languageContract = languageContract,
                  namingEdition = namingEdition,
                  moduleRoles = moduleRoles,
                  files = files,
                  mappings = mappings,
                  idDomains = idDomains,
                  nominalEqualities = nominalEqualities,
                  bindingObligations = bindingEntries,
                  behaviorRequirements = behaviorEntries,
                  projectionCatalogFacts = catalogFacts,
                  queryContractBaseline = queryContractBaseline,
                  queryContracts = queryContracts,
                  routerSelections = routerSelections,
                  semanticImpact = 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 :: Text -> Maybe ScaffoldModuleRoleRow
    parseModuleRole row = do
      payload <- T.stripPrefix "module-role " row
      decoded <- Aeson.decodeStrict' (Text.encodeUtf8 payload)
      checkedRole decoded
    checkedRole :: ScaffoldModuleRoleRow -> Maybe ScaffoldModuleRoleRow
    checkedRole roleRow = do
      path <- checkedPath (T.pack ((.path) roleRow))
      pure
        ScaffoldModuleRoleRow
          { role = (.role) roleRow,
            kind = (.kind) roleRow,
            path = 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 (.specName) 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 =
      ( (.mappedName) hole,
        (.moduleName) hole,
        (.symbol) hole,
        (.kind) hole,
        (.path) hole
      )
    hasDuplicateBehaviorRequirements requirements =
      let keys = map (.key) 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 ((.nodes) spec) <> map supplyFact supplies)
  where
    supplies = (.resolvedProjectionSupplies) supplyAnalysis
    owners = [owner | NProjectionOwner owner <- (.nodes) spec]
    nodeFacts (NProjectionTarget target) =
      [T.intercalate "|" ["target", (.name) target, (.schema) target, (.table) target, resetText ((.reset) target), T.intercalate "," ((.dependsOn) target), lineText ((.loc) target)]]
    nodeFacts (NRebuildGroup groupNode) =
      [T.intercalate "|" ["group", (.name) groupNode, T.intercalate "," (sort ((.targets) groupNode)), T.intercalate "," ((.order) groupNode), lineText ((.loc) groupNode)]]
    nodeFacts (NProjectionRevision revision) =
      [ T.intercalate
          "|"
          [ "revision",
            (.name) revision,
            (.group) revision,
            T.intercalate ";" (map revisionTargetText ((.targets) revision)),
            lineText ((.loc) revision)
          ]
      ]
    nodeFacts (NExternalRead externalRead) =
      [ T.intercalate
          "|"
          [ "external-read",
            (.name) externalRead,
            T.pack (show ((.version) externalRead)),
            (.queryModel) externalRead,
            (.resultSchema) externalRead <> "." <> (.resultType) externalRead,
            externalReadShape externalRead,
            T.intercalate "," (sort ((.compatibleRevisions) externalRead)),
            T.pack (show ((.surfaceGeneration) externalRead)),
            lineText ((.loc) externalRead)
          ]
      ]
    nodeFacts (NProjectionOwner owner) =
      [ T.intercalate
          "|"
          [ "owner",
            (.name) owner,
            T.intercalate "," (map sourceText ((.sources) owner)),
            (.group) owner,
            T.intercalate "," (sort ((.targets) owner)),
            T.pack (show ((.order) owner)),
            maybe "" id ((.subscription) owner),
            maybe "" id ((.dedup) owner),
            T.intercalate "," (map checkpointOnMissingText ((.checkpointOnMissing) owner)),
            replayText ((.replay) owner),
            lineText ((.loc) owner)
          ],
        T.intercalate
          "|"
          [ "delivery",
            (.name) owner,
            deliveryText ((.delivery) owner),
            lineText ((.loc) owner)
          ]
      ]
    nodeFacts (NReadModel readModel)
      | Just groupName <- (.group) readModel =
          [ T.intercalate
              "|"
              [ "query",
                (.name) readModel,
                groupName,
                T.intercalate "," (sort ((.observedTargets) readModel)),
                fromMaybe "" (effectiveBacking readModel),
                lineText ((.loc) readModel)
              ],
            T.intercalate
              "|"
              [ "freshness",
                (.name) readModel,
                freshnessText ((.freshness) readModel),
                lineText ((.loc) readModel)
              ],
            T.intercalate
              "|"
              [ "cursor",
                (.name) readModel,
                fromMaybe "none" (resolvedCursor readModel),
                lineText ((.loc) readModel)
              ]
          ]
    nodeFacts _ = []
    externalReadShape externalRead = case [(.shape) readModel | NReadModel readModel <- (.nodes) spec, (.name) readModel == (.queryModel) externalRead] of
      shape : _ -> shape
      [] -> "missing-query"
    revisionTargetText target =
      T.intercalate
        ","
        [ (.target) target,
          (.schemaVersion) target,
          (.provisioner) target,
          T.pack (show ((.provisionerVersion) target)),
          (.expectedShape) target,
          (.validator) target,
          T.pack (show ((.validatorVersion) target)),
          T.intercalate ":" (map promotionText ((.promotionObjects) target))
        ]
    promotionText promotionObject =
      T.intercalate
        ">"
        [ promotionKindText ((.kind) promotionObject),
          (.generationName) promotionObject,
          (.canonicalName) promotionObject
        ]
    promotionKindText PromotionIndexNode = "index"
    promotionKindText PromotionConstraintNode = "constraint"
    promotionKindText PromotionOwnedSequenceNode = "owned-sequence"
    supplyFact supply =
      T.intercalate
        "|"
        [ "supply",
          (.queryModel) supply,
          (.projectionOwner) supply,
          (.rebuildGroup) supply,
          T.intercalate "," (NE.toList ((.observedTargets) supply)),
          lineText ((.queryLoc) supply),
          lineText ((.ownerLoc) supply)
        ]
    effectiveBacking readModel = case (.backingTarget) readModel of
      Just targetName -> Just targetName
      Nothing -> case sort ((.observedTargets) 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 [ (.projectionOwner) supply
                        | supply <- supplies,
                          (.queryModel) supply == (.name) readModel
                        ] of
        [name] -> Just name
        _ -> Nothing
      owner <- case [candidate | candidate <- owners, (.name) candidate == ownerName] of
        [candidate] -> Just candidate
        _ -> Nothing
      case (.delivery) owner of
        DeliveryInline -> Nothing
        DeliverySubscription -> (.subscription) 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)