kiroku-cli-0.3.0.0: src/Kiroku/Cli/Compaction.hs
-- | Pure renderers and file loading shared by embeddable operator hosts.
module Kiroku.Cli.Compaction
( loadCompactionManifest,
CompactionCommandError (..),
renderCompactionReport,
renderCompactionRefusals,
renderCompactionRecords,
renderStoreIdentity,
renderCompactionCommandError,
renderCompactionResult,
)
where
import Control.Exception (IOException, displayException, try)
import Control.Lens ((^.))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Generics.Labels ()
import Data.Int (Int32)
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.UUID qualified as UUID
import Data.Vector (Vector)
import Data.Vector qualified as V
import Kiroku.Cli.Command (ManifestPath (..), OutputFormat (..))
import Kiroku.Store.Compaction.Types
import Kiroku.Store.Error (StoreError (..))
import Kiroku.Store.Types (GlobalPosition (..), StoreIdentity (..), StreamName (..), StreamVersion (..))
data CompactionCommandError
= ManifestUnreadable FilePath Text
| ManifestInvalid FilePath Text
| ConfirmDigestUnparseable Text
| ConfirmDigestMismatch {typed :: Text, manifest :: Text}
| LedgerLimitOutOfRange Int32
| StoreFailure StoreError
deriving stock (Eq, Show)
loadCompactionManifest :: ManifestPath -> IO (Either CompactionCommandError CompactionManifest)
loadCompactionManifest (ManifestPath path) = do
readResult <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)
pure $ case readResult of
Left err -> Left (ManifestUnreadable path (T.pack (displayException err)))
Right bytes -> case Aeson.eitherDecodeStrict bytes of
Left err -> Left (ManifestInvalid path (T.pack err))
Right value -> Right value
json :: (Aeson.ToJSON a) => a -> Text
json = TE.decodeUtf8 . LBS.toStrict . Aeson.encode
shown :: (Show a) => a -> Text
shown = T.pack . show
identityText :: StoreIdentity -> Text
identityText (StoreIdentity uid) = UUID.toText uid
positionText :: GlobalPosition -> Text
positionText (GlobalPosition n) = shown n
policyText :: (Aeson.ToJSON a) => a -> Text
policyText value = case Aeson.toJSON value of
Aeson.String text -> text
other -> json other
renderCompactionReport :: OutputFormat -> Maybe CompactionRecord -> CompactionReport -> Text
renderCompactionReport OutputJson Nothing report = json report
renderCompactionReport OutputJson (Just record) _ = json record
renderCompactionReport OutputTable context report =
maybe "" (\record -> "COMPACTION_ID " <> compactionIdText (record ^. #compactionId) <> "\n") context
<> table
["FIELD", "VALUE"]
[ ["MANIFEST_DIGEST", compactionDigestHex (report ^. #manifestDigest)],
["STORE_IDENTITY", identityText (report ^. #storeIdentity)],
["OPERATION", compactionOperationText (report ^. #operation)],
["DEAD_LETTER_POLICY", policyText (report ^. #deadLetterPolicy)],
["CAUSATION_POLICY", policyText (report ^. #causationPolicy)],
["SELECTED_EVENTS", shown (report ^. #selectedEvents)],
["HOME_MEMBERSHIPS", shown (report ^. #homeMemberships)],
["GLOBAL_MEMBERSHIPS", shown (report ^. #globalMemberships)],
["LINK_MEMBERSHIPS", shown (report ^. #linkMemberships)],
["DEAD_LETTERS_REMOVED", shown (report ^. #deadLettersRemoved)],
["CAUSATION_DEPENDENTS", shown (report ^. #causationDependents)],
["LOWEST_GLOBAL_POSITION", positionText (report ^. #lowestGlobalPosition)],
["HIGHEST_GLOBAL_POSITION", positionText (report ^. #highestGlobalPosition)],
["REPORT_DIGEST", compactionDigestHex (report ^. #reportDigest)]
]
<> "\nAFFECTED_STREAMS\n"
<> table
["STREAM", "HEAD_VERSION"]
[[name, shown version] | StreamHeadWitness (StreamName name) (StreamVersion version) <- V.toList (report ^. #affectedStreams)]
renderCompactionRefusals :: OutputFormat -> NonEmpty CompactionRefusal -> Text
renderCompactionRefusals OutputJson refusals = json (NE.toList refusals)
renderCompactionRefusals OutputTable refusals =
"refused: "
<> shown (NE.length refusals)
<> " refusal(s); no rows were changed\n"
<> table ["KIND", "EVENT_ID", "STREAM", "DETAIL"] (map row (NE.toList refusals))
where
row refusal = case Aeson.toJSON refusal of
Aeson.Object object -> [tag refusal, optional "event_id" object, optional "stream" object, shown refusal]
_ -> [tag refusal, "", "", shown refusal]
optional key object = case KeyMap.lookup key object of
Just (Aeson.String text) -> text
_ -> ""
tag = refusalName
refusalName :: CompactionRefusal -> Text
refusalName = \case
CompactionStoreIdentityMismatch {} -> "CompactionStoreIdentityMismatch"
CompactionHistoryRetentionActive {} -> "CompactionHistoryRetentionActive"
CompactionStreamMissing {} -> "CompactionStreamMissing"
CompactionStreamSoftDeleted {} -> "CompactionStreamSoftDeleted"
CompactionStreamHeadDrift {} -> "CompactionStreamHeadDrift"
CompactionSelectedEventMissing {} -> "CompactionSelectedEventMissing"
CompactionWitnessMismatch {} -> "CompactionWitnessMismatch"
CompactionUnexpectedLink {} -> "CompactionUnexpectedLink"
CompactionAcknowledgedLinkMissing {} -> "CompactionAcknowledgedLinkMissing"
CompactionDeadLettersPresent {} -> "CompactionDeadLettersPresent"
CompactionCausationDependentsPresent {} -> "CompactionCausationDependentsPresent"
CompactionLedgerConflict {} -> "CompactionLedgerConflict"
renderCompactionRecords :: OutputFormat -> Vector CompactionRecord -> Text
renderCompactionRecords OutputJson = json
renderCompactionRecords OutputTable = table ["COMPACTION_ID", "APPLIED_AT", "APPLIED_BY", "OPERATION", "SELECTED_EVENTS", "MANIFEST_DIGEST"] . map row . V.toList
where
row record =
[ compactionIdText (record ^. #compactionId),
shown (record ^. #appliedAt),
record ^. #appliedBy,
compactionOperationText (record ^. #report . #operation),
shown (record ^. #report . #selectedEvents),
compactionDigestHex (record ^. #report . #manifestDigest)
]
compactionIdText :: CompactionId -> Text
compactionIdText (CompactionId uid) = UUID.toText uid
renderStoreIdentity :: OutputFormat -> StoreIdentity -> Text
renderStoreIdentity OutputTable identity = table ["STORE_IDENTITY"] [[identityText identity]]
renderStoreIdentity OutputJson identity = json (Aeson.object ["store_identity" Aeson..= identity])
-- | JSON remains a single valid document; human status prefixes are table-only.
renderCompactionResult :: OutputFormat -> Bool -> CompactionRecord -> Text
renderCompactionResult OutputJson already record = json (Aeson.object ["result" Aeson..= (if already then "already_applied" else "applied" :: Text), "record" Aeson..= record])
renderCompactionResult OutputTable already record =
(if already then "already applied: " else "applied: ")
<> compactionIdText (record ^. #compactionId)
<> " at "
<> shown (record ^. #appliedAt)
<> " by "
<> record ^. #appliedBy
<> "\n"
<> renderCompactionReport OutputTable Nothing (record ^. #report)
renderCompactionCommandError :: OutputFormat -> CompactionCommandError -> Text
renderCompactionCommandError OutputJson err = json (Aeson.object ["error" Aeson..= errorText err])
renderCompactionCommandError OutputTable err = "kiroku: " <> errorText err
errorText :: CompactionCommandError -> Text
errorText = \case
ManifestUnreadable path detail -> "cannot read manifest " <> T.pack path <> ": " <> detail
ManifestInvalid path detail -> "invalid manifest " <> T.pack path <> ": " <> detail
ConfirmDigestUnparseable value -> "invalid confirmation digest: " <> value
ConfirmDigestMismatch typed expected -> "confirmation digest " <> typed <> " does not match manifest " <> expected
LedgerLimitOutOfRange value -> "ledger limit must be 1–1000; got " <> shown value
StoreFailure (CompactionConcurrentMutation digest guard) ->
"compaction " <> compactionDigestHex digest <> ": " <> guard <> "; a concurrent reference appeared mid-apply; nothing was deleted — re-run preview and apply"
StoreFailure err -> shown err
table :: [Text] -> [[Text]] -> Text
table headers rows = T.intercalate "\n" (map render (headers : rows))
where
widths = [maximum (map T.length (header : [row !! i | row <- rows])) | (i, header) <- zip [0 ..] headers]
render columns = T.stripEnd (T.intercalate " " (zipWith (\width value -> T.justifyLeft width ' ' value) widths columns))