packages feed

kiroku-cli 0.2.0.11 → 0.3.0.0

raw patch · 10 files changed

+546/−26 lines, 10 filesdep +temporarydep +uuiddep +vectordep ~kiroku-storePVP ok

version bump matches the API change (PVP)

Dependencies added: temporary, uuid, vector

Dependency ranges changed: kiroku-store

API changes (from Hackage documentation)

+ Kiroku.Cli: KirokuCommandOutcome :: !Text -> !ExitCode -> KirokuCommandOutcome
+ Kiroku.Cli: KirokuCompaction :: CompactionCommand -> KirokuCommand
+ Kiroku.Cli: KirokuStore :: StoreCommand -> KirokuCommand
+ Kiroku.Cli: [exitCode] :: KirokuCommandOutcome -> !ExitCode
+ Kiroku.Cli: [output] :: KirokuCommandOutcome -> !Text
+ Kiroku.Cli: data KirokuCommandOutcome
+ Kiroku.Cli: executeKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO KirokuCommandOutcome
+ Kiroku.Cli.Command: ApplyOptions :: !ManifestPath -> !Text -> !OutputFormat -> ApplyOptions
+ Kiroku.Cli.Command: CompactionApply :: ApplyOptions -> CompactionCommand
+ Kiroku.Cli.Command: CompactionLedger :: LedgerOptions -> CompactionCommand
+ Kiroku.Cli.Command: CompactionOptions :: !ManifestPath -> !OutputFormat -> CompactionOptions
+ Kiroku.Cli.Command: CompactionPreview :: CompactionOptions -> CompactionCommand
+ Kiroku.Cli.Command: IdentityOptions :: OutputFormat -> IdentityOptions
+ Kiroku.Cli.Command: KirokuCompaction :: CompactionCommand -> KirokuCommand
+ Kiroku.Cli.Command: KirokuStore :: StoreCommand -> KirokuCommand
+ Kiroku.Cli.Command: LedgerOptions :: !Int32 -> !OutputFormat -> LedgerOptions
+ Kiroku.Cli.Command: ManifestPath :: FilePath -> ManifestPath
+ Kiroku.Cli.Command: ShowStoreIdentity :: IdentityOptions -> StoreCommand
+ Kiroku.Cli.Command: [confirmDigest] :: ApplyOptions -> !Text
+ Kiroku.Cli.Command: [limit] :: LedgerOptions -> !Int32
+ Kiroku.Cli.Command: [manifestPath] :: ApplyOptions -> !ManifestPath
+ Kiroku.Cli.Command: data ApplyOptions
+ Kiroku.Cli.Command: data CompactionCommand
+ Kiroku.Cli.Command: data CompactionOptions
+ Kiroku.Cli.Command: data LedgerOptions
+ Kiroku.Cli.Command: data StoreCommand
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.ApplyOptions
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.CompactionCommand
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.CompactionOptions
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.IdentityOptions
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.LedgerOptions
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.ManifestPath
+ Kiroku.Cli.Command: instance GHC.Classes.Eq Kiroku.Cli.Command.StoreCommand
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.ApplyOptions
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.CompactionCommand
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.CompactionOptions
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.IdentityOptions
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.LedgerOptions
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.ManifestPath
+ Kiroku.Cli.Command: instance GHC.Internal.Show.Show Kiroku.Cli.Command.StoreCommand
+ Kiroku.Cli.Command: newtype IdentityOptions
+ Kiroku.Cli.Command: newtype ManifestPath
+ Kiroku.Cli.Compaction: ConfirmDigestMismatch :: Text -> Text -> CompactionCommandError
+ Kiroku.Cli.Compaction: ConfirmDigestUnparseable :: Text -> CompactionCommandError
+ Kiroku.Cli.Compaction: LedgerLimitOutOfRange :: Int32 -> CompactionCommandError
+ Kiroku.Cli.Compaction: ManifestInvalid :: FilePath -> Text -> CompactionCommandError
+ Kiroku.Cli.Compaction: ManifestUnreadable :: FilePath -> Text -> CompactionCommandError
+ Kiroku.Cli.Compaction: StoreFailure :: StoreError -> CompactionCommandError
+ Kiroku.Cli.Compaction: [manifest] :: CompactionCommandError -> Text
+ Kiroku.Cli.Compaction: [typed] :: CompactionCommandError -> Text
+ Kiroku.Cli.Compaction: data CompactionCommandError
+ Kiroku.Cli.Compaction: instance GHC.Classes.Eq Kiroku.Cli.Compaction.CompactionCommandError
+ Kiroku.Cli.Compaction: instance GHC.Internal.Show.Show Kiroku.Cli.Compaction.CompactionCommandError
+ Kiroku.Cli.Compaction: loadCompactionManifest :: ManifestPath -> IO (Either CompactionCommandError CompactionManifest)
+ Kiroku.Cli.Compaction: renderCompactionCommandError :: OutputFormat -> CompactionCommandError -> Text
+ Kiroku.Cli.Compaction: renderCompactionRecords :: OutputFormat -> Vector CompactionRecord -> Text
+ Kiroku.Cli.Compaction: renderCompactionRefusals :: OutputFormat -> NonEmpty CompactionRefusal -> Text
+ Kiroku.Cli.Compaction: renderCompactionReport :: OutputFormat -> Maybe CompactionRecord -> CompactionReport -> Text
+ Kiroku.Cli.Compaction: renderCompactionResult :: OutputFormat -> Bool -> CompactionRecord -> Text
+ Kiroku.Cli.Compaction: renderStoreIdentity :: OutputFormat -> StoreIdentity -> Text
+ Kiroku.Cli.Run: KirokuCommandOutcome :: !Text -> !ExitCode -> KirokuCommandOutcome
+ Kiroku.Cli.Run: [exitCode] :: KirokuCommandOutcome -> !ExitCode
+ Kiroku.Cli.Run: [output] :: KirokuCommandOutcome -> !Text
+ Kiroku.Cli.Run: data KirokuCommandOutcome
+ Kiroku.Cli.Run: executeKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO KirokuCommandOutcome
+ Kiroku.Cli.Run: instance GHC.Classes.Eq Kiroku.Cli.Run.KirokuCommandOutcome
+ Kiroku.Cli.Run: instance GHC.Internal.Show.Show Kiroku.Cli.Run.KirokuCommandOutcome
- Kiroku.Cli.Command: [outputFormat] :: StatusOptions -> !OutputFormat
+ Kiroku.Cli.Command: [outputFormat] :: IdentityOptions -> OutputFormat

Files

CHANGELOG.md view
@@ -1,5 +1,18 @@ # Changelog +## 0.3.0.0 — 2026-10-28++### Breaking Changes++- Add `KirokuCompaction` and `KirokuStore` constructors to `KirokuCommand`; exhaustive host matches need new arms.++### New Features++- Require `kiroku-store ^>=0.13.0.0`; maintenance outcomes use exit 0 for success/already applied, 2 for refusal and 1 for file/usage/store errors.++- Embed manifest preview, digest-confirmed apply, ledger listing and store identity commands in a host owning a `KirokuStore`. The standalone remote client refuses these commands with guidance.+- Return structured exit codes with `KirokuCommandOutcome` and `executeKirokuCommandWithStore`; expose validated manifest loading and table/JSON renderers in `Kiroku.Cli.Compaction`.+ ## 0.2.0.11 — 2026-10-26  ### Other Changes
kiroku-cli.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-cli-version:         0.2.0.11+version:         0.3.0.0 synopsis:        Embeddable operator CLI for Kiroku description:   Parser, runner, and executable entry points for Kiroku operator commands.@@ -36,6 +36,7 @@   exposed-modules:     Kiroku.Cli     Kiroku.Cli.Command+    Kiroku.Cli.Compaction     Kiroku.Cli.Parser     Kiroku.Cli.Run     Kiroku.Cli.Standalone@@ -50,10 +51,12 @@     , http-client           >=0.7       && <0.8     , http-client-tls       >=0.3       && <0.4     , http-types            >=0.12      && <0.13-    , kiroku-store          ^>=0.11.0.0 || ^>=0.12.0.0+    , kiroku-store          ^>=0.13.0.0     , lens                  >=5.2       && <5.4     , optparse-applicative  >=0.19      && <0.20     , text                  >=2.0       && <2.2+    , uuid                  >=1.3       && <1.4+    , vector                >=0.13      && <0.14    hs-source-dirs:  src @@ -72,6 +75,7 @@   import:         common   type:           exitcode-stdio-1.0   main-is:        Main.hs+  other-modules:  Test.Compaction   hs-source-dirs: test   ghc-options:    -threaded -rtsopts -with-rtsopts=-N   build-depends:@@ -83,8 +87,11 @@     , http-client           >=0.7       && <0.8     , http-client-tls       >=0.3       && <0.4     , kiroku-cli-    , kiroku-store          ^>=0.11.0.0 || ^>=0.12.0.0+    , kiroku-store          ^>=0.13.0.0     , kiroku-test-support     , lens     , optparse-applicative+    , temporary             >=1.3       && <1.4     , text+    , uuid                  >=1.3       && <1.4+    , vector                >=0.13      && <0.14
src/Kiroku/Cli.hs view
@@ -6,9 +6,11 @@     runKirokuCommand,     runKirokuCommandWithStore,     renderKirokuCommandWithStore,+    KirokuCommandOutcome (..),+    executeKirokuCommandWithStore,   ) where  import Kiroku.Cli.Command (KirokuCommand (..)) import Kiroku.Cli.Parser (kirokuCommandParser, kirokuParserInfo, kirokuSubparser)-import Kiroku.Cli.Run (renderKirokuCommandWithStore, runKirokuCommand, runKirokuCommandWithStore)+import Kiroku.Cli.Run (KirokuCommandOutcome (..), executeKirokuCommandWithStore, renderKirokuCommandWithStore, runKirokuCommand, runKirokuCommandWithStore)
src/Kiroku/Cli/Command.hs view
@@ -4,14 +4,24 @@     RemoteEndpoint (..),     StatusOptions (..),     SubscriptionCommand (..),+    CompactionCommand (..),+    StoreCommand (..),+    ManifestPath (..),+    CompactionOptions (..),+    ApplyOptions (..),+    LedgerOptions (..),+    IdentityOptions (..),   ) where +import Data.Int (Int32) import Data.Text (Text)  data KirokuCommand   = KirokuNoCommand   | KirokuSubscriptions SubscriptionCommand+  | KirokuCompaction CompactionCommand+  | KirokuStore StoreCommand   deriving stock (Eq, Show)  data SubscriptionCommand@@ -34,4 +44,32 @@ data OutputFormat   = OutputTable   | OutputJson+  deriving stock (Eq, Show)++-- | Maintenance commands run only in a host that owns a live store.+data CompactionCommand+  = CompactionPreview CompactionOptions+  | CompactionApply ApplyOptions+  | CompactionLedger LedgerOptions+  deriving stock (Eq, Show)++data StoreCommand = ShowStoreIdentity IdentityOptions+  deriving stock (Eq, Show)++newtype ManifestPath = ManifestPath FilePath+  deriving stock (Eq, Show)++data CompactionOptions = CompactionOptions+  {manifestPath :: !ManifestPath, outputFormat :: !OutputFormat}+  deriving stock (Eq, Show)++data ApplyOptions = ApplyOptions+  {manifestPath :: !ManifestPath, confirmDigest :: !Text, outputFormat :: !OutputFormat}+  deriving stock (Eq, Show)++data LedgerOptions = LedgerOptions+  {limit :: !Int32, outputFormat :: !OutputFormat}+  deriving stock (Eq, Show)++newtype IdentityOptions = IdentityOptions {outputFormat :: OutputFormat}   deriving stock (Eq, Show)
+ src/Kiroku/Cli/Compaction.hs view
@@ -0,0 +1,180 @@+-- | 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))
src/Kiroku/Cli/Parser.hs view
@@ -14,6 +14,7 @@     Mod,     Parser,     ParserInfo,+    auto,     command,     eitherReader,     fullDesc,@@ -42,6 +43,8 @@                   (KirokuSubscriptions <$> subscriptionCommandParser)                   (fullDesc <> progDesc "Inspect live subscriptions in this process's KirokuStore registry.")               )+              <> command "compaction" (info (KirokuCompaction <$> compactionCommandParser) (fullDesc <> progDesc "Preview, apply, or audit a selective compaction manifest (embeddable only)."))+              <> command "store" (info (KirokuStore <$> storeCommandParser) (fullDesc <> progDesc "Inspect the live store identity (embeddable only)."))           )       ) @@ -101,3 +104,20 @@             <> progDesc "Run Kiroku operator commands."         )     )++compactionCommandParser :: Parser Command.CompactionCommand+compactionCommandParser =+  subparser+    ( command "preview" (info (Command.CompactionPreview <$> (Command.CompactionOptions <$> manifestPathParser <*> outputFormatParser) <**> helper) (progDesc "Validate a manifest without changing any rows."))+        <> command "apply" (info (Command.CompactionApply <$> (Command.ApplyOptions <$> manifestPathParser <*> (T.pack <$> strOption (long "confirm-digest" <> metavar "HEX" <> help "Reviewed manifest digest; must match the file.")) <*> outputFormatParser) <**> helper) (progDesc "Apply an explicitly confirmed manifest atomically."))+        <> command "ledger" (info (Command.CompactionLedger <$> (Command.LedgerOptions <$> option auto (long "limit" <> metavar "N" <> value 50 <> help "Newest records to show (1–1000).") <*> outputFormatParser) <**> helper) (progDesc "List durable compaction audit records."))+    )++storeCommandParser :: Parser Command.StoreCommand+storeCommandParser = subparser (command "identity" (info (Command.ShowStoreIdentity . Command.IdentityOptions <$> outputFormatParser <**> helper) (progDesc "Read this store's immutable identity.")))++manifestPathParser :: Parser Command.ManifestPath+manifestPathParser = Command.ManifestPath <$> strOption (long "manifest" <> metavar "FILE" <> help "Digest-sealed compaction manifest JSON file.")++outputFormatParser :: Parser Command.OutputFormat+outputFormatParser = option (eitherReader parseOutputFormat) (long "format" <> metavar "table|json" <> value Command.OutputTable <> help "Render a human table or JSON.")
src/Kiroku/Cli/Run.hs view
@@ -2,38 +2,91 @@   ( runKirokuCommand,     runKirokuCommandWithStore,     renderKirokuCommandWithStore,+    KirokuCommandOutcome (..),+    executeKirokuCommandWithStore,   ) where +import Control.Monad (when) import Data.Text (Text) import Data.Text.IO qualified as TIO-import Kiroku.Cli.Command (KirokuCommand (..), StatusOptions (..), SubscriptionCommand (..))-import Kiroku.Cli.Subscription.Status-  ( renderRemoteSubscriptionStatus,-    renderSubscriptionStatusRows,-    subscriptionStatusRows,-  )+import Kiroku.Cli.Command+import Kiroku.Cli.Compaction+import Kiroku.Cli.Subscription.Status (renderRemoteSubscriptionStatus, renderSubscriptionStatusRows, subscriptionStatusRows) import Kiroku.Store (KirokuStore)+import Kiroku.Store.Compaction qualified as Store+import Kiroku.Store.Effect (runStoreIO)+import Kiroku.Store.Read (storeIdentity) import Kiroku.Store.Subscription (subscriptionStates)+import System.Exit (ExitCode (..), exitWith) +data KirokuCommandOutcome = KirokuCommandOutcome {output :: !Text, exitCode :: !ExitCode}+  deriving stock (Eq, Show)+ runKirokuCommand :: KirokuCommand -> IO ()-runKirokuCommand KirokuNoCommand =-  putStrLn "No Kiroku operator command was selected."-runKirokuCommand (KirokuSubscriptions _) =-  putStrLn "This command needs a live KirokuStore. Use runKirokuCommandWithStore from the embeddable library API."+runKirokuCommand KirokuNoCommand = putStrLn "No Kiroku operator command was selected."+runKirokuCommand (KirokuSubscriptions _) = putStrLn storeRequired+runKirokuCommand (KirokuCompaction _) = putStrLn storeRequired+runKirokuCommand (KirokuStore _) = putStrLn storeRequired +storeRequired :: String+storeRequired = "This command needs a live KirokuStore. Use runKirokuCommandWithStore from the embeddable library API."+ runKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO ()-runKirokuCommandWithStore store command =-  renderKirokuCommandWithStore store command >>= TIO.putStrLn+runKirokuCommandWithStore store command = do+  KirokuCommandOutcome {output, exitCode} <- executeKirokuCommandWithStore store command+  TIO.putStrLn output+  when (exitCode /= ExitSuccess) (exitWith exitCode)  renderKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO Text-renderKirokuCommandWithStore _ KirokuNoCommand =-  pure "No Kiroku operator command was selected."-renderKirokuCommandWithStore store (KirokuSubscriptions (SubscriptionStatus (StatusOptions format endpoint))) =-  case endpoint of-    -- An optional remote override: a host can point the in-process command at-    -- a sibling worker over HTTP instead of reading its own registry.+renderKirokuCommandWithStore store command = do+  KirokuCommandOutcome {output} <- executeKirokuCommandWithStore store command+  pure output++-- | Structured execution for hosts. Refusals use exit 2, load/store errors use+-- exit 1, and success/already-applied use exit 0. Legacy status behavior stays+-- compatible, including its rendered remote-fetch errors with exit 0.+executeKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO KirokuCommandOutcome+executeKirokuCommandWithStore _ KirokuNoCommand = pure (success "No Kiroku operator command was selected.")+executeKirokuCommandWithStore store (KirokuSubscriptions (SubscriptionStatus (StatusOptions format endpoint))) =+  success <$> case endpoint of     Just ep -> renderRemoteSubscriptionStatus ep format-    Nothing -> do-      snapshot <- subscriptionStates store-      pure (renderSubscriptionStatusRows format (subscriptionStatusRows snapshot))+    Nothing -> renderSubscriptionStatusRows format . subscriptionStatusRows <$> subscriptionStates store+executeKirokuCommandWithStore store (KirokuStore (ShowStoreIdentity (IdentityOptions format))) = do+  result <- runStoreIO store storeIdentity+  pure $ either (failure format 1 . StoreFailure) (success . renderStoreIdentity format) result+executeKirokuCommandWithStore store (KirokuCompaction command) = case command of+  CompactionPreview (CompactionOptions path format) -> withManifest path format $ \manifest -> do+    result <- runStoreIO store (Store.previewCompaction manifest)+    pure $ case result of+      Left err -> failure format 1 (StoreFailure err)+      Right (Left refusals) -> KirokuCommandOutcome (renderCompactionRefusals format refusals) (ExitFailure 2)+      Right (Right (Store.CompactionPreviewReady report)) -> success (renderCompactionReport format Nothing report)+      Right (Right (Store.CompactionPreviewAlreadyApplied record)) -> success (renderCompactionResult format True record)+  CompactionApply (ApplyOptions path typedDigest format) -> withManifest path format $ \manifest ->+    case Store.parseCompactionDigestHex typedDigest of+      Nothing -> pure (failure format 1 (ConfirmDigestUnparseable typedDigest))+      Just digest+        | digest /= Store.compactionManifestDigest manifest ->+            pure (failure format 2 (ConfirmDigestMismatch typedDigest (Store.compactionDigestHex (Store.compactionManifestDigest manifest))))+      Just _ -> do+        result <- runStoreIO store (Store.applyCompaction manifest)+        pure $ case result of+          Left err -> failure format 1 (StoreFailure err)+          Right (Left refusals) -> KirokuCommandOutcome (renderCompactionRefusals format refusals) (ExitFailure 2)+          Right (Right (Store.CompactionAppliedNow record)) -> success (renderCompactionResult format False record)+          Right (Right (Store.CompactionAlreadyApplied record)) -> success (renderCompactionResult format True record)+  CompactionLedger (LedgerOptions limit format) -> case Store.mkCompactionLedgerLimit limit of+    Left _ -> pure (failure format 1 (LedgerLimitOutOfRange limit))+    Right validated -> do+      result <- runStoreIO store (Store.compactionLedger (Store.CompactionLedgerQuery validated))+      pure $ either (failure format 1 . StoreFailure) (success . renderCompactionRecords format) result++withManifest :: ManifestPath -> OutputFormat -> (Store.CompactionManifest -> IO KirokuCommandOutcome) -> IO KirokuCommandOutcome+withManifest path format continuation = loadCompactionManifest path >>= either (pure . failure format 1) continuation++success :: Text -> KirokuCommandOutcome+success text = KirokuCommandOutcome text ExitSuccess++failure :: OutputFormat -> Int -> CompactionCommandError -> KirokuCommandOutcome+failure format code err = KirokuCommandOutcome (renderCompactionCommandError format err) (ExitFailure code)
src/Kiroku/Cli/Standalone.hs view
@@ -55,7 +55,7 @@         <> progDesc           "Inspect a running Kiroku worker over HTTP. Subscription status queries the worker's \           \kiroku-metrics /subscriptions endpoint (--remote-url or KIROKU_REMOTE_URL); the binary \-          \opens no store of its own."+          \opens no store of its own. Compaction commands are embeddable-only."         <> header "kiroku - remote operator client for Kiroku event stores"     ) @@ -71,6 +71,8 @@ resolveStandaloneOptions env (StandaloneOptions cmd) =   case cmd of     KirokuNoCommand -> Right (StandaloneRuntime KirokuNoCommand)+    KirokuCompaction _ -> Left embeddableOnlyMessage+    KirokuStore _ -> Left embeddableOnlyMessage     KirokuSubscriptions (SubscriptionStatus (StatusOptions format mEndpoint)) ->       case mEndpoint <|> envEndpoint of         Just ep ->@@ -93,9 +95,14 @@ runStandaloneCommand (StandaloneRuntime cmd) =   case cmd of     KirokuNoCommand -> pure "No Kiroku operator command was selected."+    KirokuCompaction _ -> pure embeddableOnlyMessage+    KirokuStore _ -> pure embeddableOnlyMessage     KirokuSubscriptions (SubscriptionStatus (StatusOptions format (Just ep))) ->       renderRemoteSubscriptionStatus ep format     KirokuSubscriptions (SubscriptionStatus (StatusOptions _ Nothing)) ->       -- Unreachable: resolveStandaloneOptions fills the endpoint or fails.       pure         "kiroku: no worker endpoint; pass --remote-url or set KIROKU_REMOTE_URL."++embeddableOnlyMessage :: Text+embeddableOnlyMessage = "kiroku: compaction and store identity commands run only inside a host that owns a KirokuStore; the standalone binary is a remote client"
test/Main.hs view
@@ -29,6 +29,7 @@     (<**>),   ) import Options.Applicative qualified as Options+import Test.Compaction qualified as Compaction import Test.Hspec  data HostCommand@@ -39,6 +40,7 @@ main :: IO () main =   hspec $ do+    Compaction.spec     describe "kirokuParserInfo" $ do       it "renders top-level help" $ do         let result = execParserPure defaultPrefs kirokuParserInfo ["--help"]
+ test/Test/Compaction.hs view
@@ -0,0 +1,198 @@+module Test.Compaction (spec) where++import Control.Concurrent (myThreadId)+import Control.Exception (try)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (forM_, when)+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.Either (isRight)+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Text.IO qualified as TIO+import Data.UUID qualified as UUID+import Data.Vector qualified as V+import Kiroku.Cli.Command qualified as C+import Kiroku.Cli.Compaction+import Kiroku.Cli.Parser+import Kiroku.Cli.Run+import Kiroku.Cli.Standalone+import Kiroku.Store hiding (id)+import Kiroku.Test.Postgres (withMigratedTestDatabase)+import Options.Applicative qualified as Options+import System.Exit (ExitCode (..))+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec++spec :: Spec+spec = describe "compaction CLI" $ do+  it "parses all maintenance commands and requires explicit apply confirmation" $ do+    let cases =+          [ (["compaction", "preview", "--manifest", "m.json"], C.KirokuCompaction (C.CompactionPreview (C.CompactionOptions (C.ManifestPath "m.json") C.OutputTable))),+            (["compaction", "apply", "--manifest", "m.json", "--confirm-digest", "ab12", "--format", "json"], C.KirokuCompaction (C.CompactionApply (C.ApplyOptions (C.ManifestPath "m.json") "ab12" C.OutputJson))),+            (["compaction", "ledger"], C.KirokuCompaction (C.CompactionLedger (C.LedgerOptions 50 C.OutputTable))),+            (["compaction", "ledger", "--limit", "5"], C.KirokuCompaction (C.CompactionLedger (C.LedgerOptions 5 C.OutputTable))),+            (["store", "identity"], C.KirokuStore (C.ShowStoreIdentity (C.IdentityOptions C.OutputTable)))+          ]+    forM_ cases $ \(args, expected) -> case Options.execParserPure Options.defaultPrefs kirokuParserInfo args of+      Options.Success actual -> actual `shouldBe` expected+      _ -> expectationFailure ("could not parse " <> show args)+    Options.execParserPure Options.defaultPrefs kirokuParserInfo ["compaction", "apply", "--manifest", "m.json"] `shouldSatisfy` \case Options.Failure _ -> True; _ -> False++  it "embeds the same compaction command under an unrelated host parser" $ do+    let parser = Options.info (Options.subparser (kirokuSubparser Just)) Options.fullDesc+    case Options.execParserPure Options.defaultPrefs parser ["kiroku", "compaction", "preview", "--manifest", "m.json"] of+      Options.Success actual -> actual `shouldBe` Just (previewCommand "m.json" C.OutputTable)+      _ -> expectationFailure "nested compaction parser failed"++  it "refuses compaction and identity in the standalone remote client" $ do+    let commands = [previewCommand "m.json" C.OutputTable, C.KirokuCompaction (C.CompactionLedger (C.LedgerOptions 50 C.OutputTable)), C.KirokuStore (C.ShowStoreIdentity (C.IdentityOptions C.OutputTable))]+    forM_ commands $ \command -> case resolveStandaloneOptions [] (StandaloneOptions command) of+      Left message -> message `shouldBe` embeddableMessage+      Right _ -> expectationFailure "standalone accepted a store-owned maintenance command"++  it "returns typed file errors and detects corrupted manifest digests before store access" $+    withSystemTempDirectory "kiroku-manifest" $ \directory -> do+      let path = directory <> "/manifest.json"+      missing <- loadCompactionManifest (C.ManifestPath path)+      missing `shouldSatisfy` \case Left (ManifestUnreadable _ _) -> True; _ -> False+      TIO.writeFile path "not JSON"+      invalid <- loadCompactionManifest (C.ManifestPath path)+      invalid `shouldSatisfy` \case Left (ManifestInvalid _ _) -> True; _ -> False+      let Aeson.Object object = Aeson.toJSON sampleManifest+      Aeson.encodeFile path (Aeson.Object (KM.insert "digest" (Aeson.String (T.replicate 64 "0")) object))+      damaged <- loadCompactionManifest (C.ManifestPath path)+      damaged `shouldSatisfy` \case Left (ManifestInvalid _ message) -> T.isInfixOf "CompactionManifestDigestMismatch" message; _ -> False++  it "renders stable empty-ledger, identity and refusal tables and valid JSON" $ do+    renderCompactionRecords C.OutputTable V.empty `shouldBe` "COMPACTION_ID  APPLIED_AT  APPLIED_BY  OPERATION  SELECTED_EVENTS  MANIFEST_DIGEST"+    renderStoreIdentity C.OutputTable (StoreIdentity UUID.nil) `shouldBe` "STORE_IDENTITY\n00000000-0000-0000-0000-000000000000"+    renderStoreIdentity C.OutputJson (StoreIdentity UUID.nil) `shouldBe` "{\"store_identity\":\"00000000-0000-0000-0000-000000000000\"}"+    let refusals = CompactionSelectedEventMissing (EventId UUID.nil) :| []+        table = renderCompactionRefusals C.OutputTable refusals+    table `shouldSatisfy` T.isPrefixOf "refused: 1 refusal(s); no rows were changed\nKIND"+    table `shouldSatisfy` T.isInfixOf "CompactionSelectedEventMissing"+    validJSON (renderCompactionRefusals C.OutputJson refusals)+    validJSON (renderCompactionRecords C.OutputJson V.empty)+    let digest = compactionManifestDigest sampleManifest+    renderCompactionCommandError C.OutputTable (StoreFailure (CompactionConcurrentMutation digest "dead-letter count")) `shouldSatisfy` T.isInfixOf "re-run preview and apply"++  it "executes preview, confirmed apply, no-op reapply, ledger and identity against a live store" $+    withSystemTempDirectory "kiroku-live-manifest" $ \directory -> withTestStore $ \store -> do+      let path = directory <> "/manifest.json"+          driftPath = directory <> "/drift.json"+      input <- liveInput store+      let Right m = mkCompactionManifest input+          Right drift = mkCompactionManifest (input & #streamHeads .~ [StreamHeadWitness (StreamName "orders-cli") (StreamVersion 4)])+          digest = compactionDigestHex (compactionManifestDigest m)+      Aeson.encodeFile path m+      Aeson.encodeFile driftPath drift+      identity <- executeKirokuCommandWithStore store (C.KirokuStore (C.ShowStoreIdentity (C.IdentityOptions C.OutputJson)))+      assertOutcome ExitSuccess "store_identity" identity+      assertJSON identity+      ready <- executeKirokuCommandWithStore store (previewCommand path C.OutputTable)+      assertOutcome ExitSuccess "SELECTED_EVENTS" ready+      readyJSON <- executeKirokuCommandWithStore store (previewCommand path C.OutputJson)+      assertOutcome ExitSuccess "selected_events" readyJSON+      assertJSON readyJSON+      refusedJSON <- executeKirokuCommandWithStore store (previewCommand driftPath C.OutputJson)+      assertOutcome (ExitFailure 2) "stream_head_drift" refusedJSON+      assertJSON refusedJSON+      refused <- executeKirokuCommandWithStore store (previewCommand driftPath C.OutputTable)+      assertOutcome (ExitFailure 2) "CompactionStreamHeadDrift" refused+      exit <- try (runKirokuCommandWithStore store (previewCommand driftPath C.OutputTable)) :: IO (Either ExitCode ())+      exit `shouldBe` Left (ExitFailure 2)+      denied <- executeKirokuCommandWithStore store (applyCommand path (T.replicate 64 "0") C.OutputTable)+      assertOutcome (ExitFailure 2) "does not match" denied+      eventsBefore <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+      V.length eventsBefore `shouldBe` 3+      first <- executeKirokuCommandWithStore store (applyCommand path digest C.OutputTable)+      assertOutcome ExitSuccess "applied:" first+      again <- executeKirokuCommandWithStore store (applyCommand path digest C.OutputTable)+      assertOutcome ExitSuccess "already applied:" again+      previewed <- executeKirokuCommandWithStore store (previewCommand path C.OutputTable)+      assertOutcome ExitSuccess "already applied:" previewed+      forM_ [applyCommand path digest C.OutputJson, previewCommand path C.OutputJson, C.KirokuCompaction (C.CompactionLedger (C.LedgerOptions 10 C.OutputJson))] $ \command -> do+        result <- executeKirokuCommandWithStore store command+        assertOutcome ExitSuccess "" result+        assertJSON result+      eventsAfter <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+      map (\e -> e ^. #originalVersion) (V.toList eventsAfter) `shouldBe` [StreamVersion 1, StreamVersion 3]+      records <- storeRead =<< runStoreIO store (compactionLedger (CompactionLedgerQuery (either (error . show) id (mkCompactionLedgerLimit 10))))+      V.length records `shouldBe` 1+      ledger <- executeKirokuCommandWithStore store (C.KirokuCompaction (C.CompactionLedger (C.LedgerOptions 10 C.OutputTable)))+      assertOutcome ExitSuccess "MANIFEST_DIGEST" ledger++  it "rejects confirmation errors and invalid limits without any caller pool checkout" $+    withSystemTempDirectory "kiroku-confirm-manifest" $ \directory -> do+      calls <- newIORef (0 :: Int)+      owner <- myThreadId+      withMigratedTestDatabase $ \conn -> do+        let settings =+              (defaultConnectionSettings conn)+                { observationHandler = Just $ \case+                    ConnectionObservation _ InUseConnectionStatus -> do current <- myThreadId; when (current == owner) (modifyIORef' calls (+ 1))+                    _ -> pure ()+                }+        withStore settings $ \store -> do+          let path = directory <> "/manifest.json"+          Aeson.encodeFile path sampleManifest+          initialCalls <- readIORef calls+          mismatch <- executeKirokuCommandWithStore store (applyCommand path (T.replicate 64 "0") C.OutputJson)+          assertOutcome (ExitFailure 2) "does not match" mismatch+          assertJSON mismatch+          unparseable <- executeKirokuCommandWithStore store (applyCommand path "invalid" C.OutputTable)+          assertOutcome (ExitFailure 1) "invalid confirmation" unparseable+          invalidLimit <- executeKirokuCommandWithStore store (C.KirokuCompaction (C.CompactionLedger (C.LedgerOptions 0 C.OutputJson)))+          assertOutcome (ExitFailure 1) "ledger limit" invalidLimit+          unreadable <- executeKirokuCommandWithStore store (previewCommand (directory <> "/missing") C.OutputJson)+          assertOutcome (ExitFailure 1) "cannot read manifest" unreadable+          assertJSON unreadable+          readIORef calls `shouldReturn` initialCalls++previewCommand :: FilePath -> C.OutputFormat -> C.KirokuCommand+previewCommand path format = C.KirokuCompaction (C.CompactionPreview (C.CompactionOptions (C.ManifestPath path) format))++applyCommand :: FilePath -> Text -> C.OutputFormat -> C.KirokuCommand+applyCommand path digest format = C.KirokuCompaction (C.CompactionApply (C.ApplyOptions (C.ManifestPath path) digest format))++assertOutcome :: ExitCode -> Text -> KirokuCommandOutcome -> Expectation+assertOutcome expected needle (KirokuCommandOutcome output actual) = do+  actual `shouldBe` expected+  output `shouldSatisfy` T.isInfixOf needle++assertJSON :: KirokuCommandOutcome -> Expectation+assertJSON (KirokuCommandOutcome output _) = validJSON output++validJSON :: Text -> Expectation+validJSON output = (Aeson.eitherDecodeStrict (TE.encodeUtf8 output) :: Either String Aeson.Value) `shouldSatisfy` isRight++embeddableMessage :: Text+embeddableMessage = "kiroku: compaction and store identity commands run only inside a host that owns a KirokuStore; the standalone binary is a remote client"++sampleManifest :: CompactionManifest+sampleManifest = either (error . show) id (mkCompactionManifest (CompactionManifestInput (StoreIdentity UUID.nil) op RefuseDeadLetters RefuseCausationDependents [StreamHeadWitness name (StreamVersion 1)] [CompactionSelection (EventId UUID.nil) name (StreamVersion 1) (GlobalPosition 1) V.empty] Nothing))+  where+    name = StreamName "sample-1"+    op = either (error . show) id (mkCompactionOperation "sample")++withTestStore :: (KirokuStore -> IO a) -> IO a+withTestStore action = withMigratedTestDatabase $ \conn -> withStore (defaultConnectionSettings conn) action++storeRead :: (Show e) => Either e a -> IO a+storeRead = either (fail . show) pure++liveInput :: KirokuStore -> IO CompactionManifestInput+liveInput store = do+  let name = StreamName "orders-cli"+      event = EventData Nothing (EventType "CLI") (Aeson.object []) Nothing Nothing Nothing+  _ <- storeRead =<< runStoreIO store (appendToStream name NoStream [event, event, event])+  events <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 10)+  identity <- storeRead =<< runStoreIO store storeIdentity+  op <- either (fail . show) pure (mkCompactionOperation "cli-test")+  let selected = events V.! 1+  pure (CompactionManifestInput identity op RefuseDeadLetters RefuseCausationDependents [StreamHeadWitness name (StreamVersion 3)] [CompactionSelection (selected ^. #eventId) name (selected ^. #originalVersion) (selected ^. #globalPosition) V.empty] Nothing)