kiroku-cli (empty) → 0.1.0.0
raw patch · 10 files changed
Files
- CHANGELOG.md +10/−0
- app/Main.hs +26/−0
- kiroku-cli.cabal +84/−0
- src/Kiroku/Cli.hs +13/−0
- src/Kiroku/Cli/Command.hs +25/−0
- src/Kiroku/Cli/Parser.hs +90/−0
- src/Kiroku/Cli/Run.hs +29/−0
- src/Kiroku/Cli/Standalone.hs +118/−0
- src/Kiroku/Cli/Subscription/Status.hs +93/−0
- test/Main.hs +265/−0
+ CHANGELOG.md view
@@ -0,0 +1,10 @@+# Changelog++## Unreleased++## 0.1.0.0 — 2026-05-31++### New Features++* Initial package with embeddable parser, runner facade, executable wrapper,+ and parser composition tests.
+ app/Main.hs view
@@ -0,0 +1,26 @@+module Main where++import Control.Exception (SomeException, try)+import Data.Text qualified as T+import Data.Text.IO qualified as TIO+import Kiroku.Cli.Standalone (resolveStandaloneOptions, runStandaloneCommand, standaloneParserInfo)+import Options.Applicative (execParser)+import System.Environment (getEnvironment)+import System.Exit (exitFailure)+import System.IO (stderr)++main :: IO ()+main = do+ opts <- execParser standaloneParserInfo+ env <- getEnvironment+ case resolveStandaloneOptions env opts of+ Left err -> do+ TIO.hPutStrLn stderr err+ exitFailure+ Right runtime -> do+ result <- try (runStandaloneCommand runtime)+ case result of+ Left (err :: SomeException) -> do+ TIO.hPutStrLn stderr ("kiroku: " <> T.pack (show err))+ exitFailure+ Right output -> TIO.putStrLn output
+ kiroku-cli.cabal view
@@ -0,0 +1,84 @@+cabal-version: 3.0+name: kiroku-cli+version: 0.1.0.0+synopsis: Embeddable operator CLI for Kiroku+description:+ Parser, runner, and executable entry points for Kiroku operator commands.+ The library is designed for embedding in host application CLIs, while the+ executable is a thin wrapper over the same parser and runner.++homepage: https://github.com/shinzui/kiroku+bug-reports: https://github.com/shinzui/kiroku/issues+author: Nadeem Bitar+maintainer: nadeem@gmail.com+license: BSD-3-Clause+build-type: Simple+category: Database, Eventing+extra-doc-files: CHANGELOG.md++source-repository head+ type: git+ location: https://github.com/shinzui/kiroku.git++common common+ default-language: GHC2024+ default-extensions:+ DeriveAnyClass+ DuplicateRecordFields+ OverloadedLabels+ OverloadedStrings++ ghc-options: -Wall++library+ import: common+ exposed-modules:+ Kiroku.Cli+ Kiroku.Cli.Command+ Kiroku.Cli.Parser+ Kiroku.Cli.Run+ Kiroku.Cli.Standalone+ Kiroku.Cli.Subscription.Status++ build-depends:+ , aeson >=2.1 && <2.3+ , base >=4.18 && <5+ , bytestring >=0.11 && <0.13+ , containers >=0.6 && <0.8+ , generic-lens >=2.2 && <2.4+ , kiroku-store ^>=0.2+ , lens >=5.2 && <5.4+ , optparse-applicative >=0.19 && <0.20+ , text >=2.0 && <2.2++ hs-source-dirs: src++executable kiroku+ import: common+ main-is: Main.hs+ hs-source-dirs: app+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ build-depends:+ , base+ , kiroku-cli+ , optparse-applicative+ , text++test-suite kiroku-cli-test+ import: common+ type: exitcode-stdio-1.0+ main-is: Main.hs+ hs-source-dirs: test+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ build-depends:+ , aeson+ , base+ , containers+ , generic-lens >=2.2 && <2.4+ , hspec >=2.10 && <2.12+ , kiroku-cli+ , kiroku-store+ , kiroku-test-support+ , lens+ , optparse-applicative+ , text
+ src/Kiroku/Cli.hs view
@@ -0,0 +1,13 @@+module Kiroku.Cli (+ KirokuCommand (..),+ kirokuCommandParser,+ kirokuParserInfo,+ kirokuSubparser,+ runKirokuCommand,+ runKirokuCommandWithStore,+ renderKirokuCommandWithStore,+) where++import Kiroku.Cli.Command (KirokuCommand (..))+import Kiroku.Cli.Parser (kirokuCommandParser, kirokuParserInfo, kirokuSubparser)+import Kiroku.Cli.Run (renderKirokuCommandWithStore, runKirokuCommand, runKirokuCommandWithStore)
+ src/Kiroku/Cli/Command.hs view
@@ -0,0 +1,25 @@+module Kiroku.Cli.Command (+ KirokuCommand (..),+ OutputFormat (..),+ StatusOptions (..),+ SubscriptionCommand (..),+) where++data KirokuCommand+ = KirokuNoCommand+ | KirokuSubscriptions SubscriptionCommand+ deriving stock (Eq, Show)++data SubscriptionCommand+ = SubscriptionStatus StatusOptions+ deriving stock (Eq, Show)++newtype StatusOptions = StatusOptions+ { outputFormat :: OutputFormat+ }+ deriving stock (Eq, Show)++data OutputFormat+ = OutputTable+ | OutputJson+ deriving stock (Eq, Show)
+ src/Kiroku/Cli/Parser.hs view
@@ -0,0 +1,90 @@+module Kiroku.Cli.Parser (+ kirokuCommandParser,+ kirokuParserInfo,+ kirokuSubparser,+) where++import Control.Applicative (optional)+import Kiroku.Cli.Command (KirokuCommand (..))+import Kiroku.Cli.Command qualified as Command+import Options.Applicative (+ CommandFields,+ Mod,+ Parser,+ ParserInfo,+ command,+ eitherReader,+ fullDesc,+ header,+ help,+ helper,+ info,+ long,+ metavar,+ option,+ progDesc,+ subparser,+ value,+ (<**>),+ )++kirokuCommandParser :: Parser KirokuCommand+kirokuCommandParser =+ maybe KirokuNoCommand id+ <$> optional+ ( subparser+ ( command+ "subscriptions"+ ( info+ (KirokuSubscriptions <$> subscriptionCommandParser)+ (fullDesc <> progDesc "Inspect live subscriptions in this process's KirokuStore registry.")+ )+ )+ )++subscriptionCommandParser :: Parser Command.SubscriptionCommand+subscriptionCommandParser =+ subparser+ ( command+ "status"+ ( info+ (Command.SubscriptionStatus <$> statusOptionsParser <**> helper)+ (fullDesc <> progDesc "List live subscription phases and global cursor positions.")+ )+ )++statusOptionsParser :: Parser Command.StatusOptions+statusOptionsParser =+ Command.StatusOptions+ <$> option+ (eitherReader parseOutputFormat)+ ( long "format"+ <> metavar "table|json"+ <> value Command.OutputTable+ <> help "Render as a human table or script-friendly JSON."+ )++parseOutputFormat :: String -> Either String Command.OutputFormat+parseOutputFormat "table" = Right Command.OutputTable+parseOutputFormat "json" = Right Command.OutputJson+parseOutputFormat other = Left ("unsupported output format " <> show other <> "; expected table or json")++kirokuParserInfo :: ParserInfo KirokuCommand+kirokuParserInfo =+ info+ (kirokuCommandParser <**> helper)+ ( fullDesc+ <> progDesc "Run Kiroku operator commands."+ <> header "kiroku - operator commands for Kiroku event stores"+ )++kirokuSubparser :: (KirokuCommand -> command) -> Mod CommandFields command+kirokuSubparser wrap =+ command+ "kiroku"+ ( info+ (wrap <$> kirokuCommandParser <**> helper)+ ( fullDesc+ <> progDesc "Run Kiroku operator commands."+ )+ )
+ src/Kiroku/Cli/Run.hs view
@@ -0,0 +1,29 @@+module Kiroku.Cli.Run (+ runKirokuCommand,+ runKirokuCommandWithStore,+ renderKirokuCommandWithStore,+) where++import Data.Text (Text)+import Data.Text.IO qualified as TIO+import Kiroku.Cli.Command (KirokuCommand (..), StatusOptions (..), SubscriptionCommand (..))+import Kiroku.Cli.Subscription.Status (renderSubscriptionStatusRows, subscriptionStatusRows)+import Kiroku.Store (KirokuStore)+import Kiroku.Store.Subscription (subscriptionStates)++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."++runKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO ()+runKirokuCommandWithStore store command =+ renderKirokuCommandWithStore store command >>= TIO.putStrLn++renderKirokuCommandWithStore :: KirokuStore -> KirokuCommand -> IO Text+renderKirokuCommandWithStore _ KirokuNoCommand =+ pure "No Kiroku operator command was selected."+renderKirokuCommandWithStore store (KirokuSubscriptions (SubscriptionStatus (StatusOptions format))) = do+ snapshot <- subscriptionStates store+ pure (renderSubscriptionStatusRows format (subscriptionStatusRows snapshot))
+ src/Kiroku/Cli/Standalone.hs view
@@ -0,0 +1,118 @@+module Kiroku.Cli.Standalone (+ StandaloneOptions (..),+ StandaloneRuntime (..),+ standaloneOptionsParser,+ standaloneParserInfo,+ resolveStandaloneOptions,+ runStandaloneCommand,+) where++import Control.Applicative (optional, (<|>))+import Control.Lens ((&), (.~))+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as T+import Kiroku.Cli.Command (KirokuCommand (..), OutputFormat (..), StatusOptions (..), SubscriptionCommand (..))+import Kiroku.Cli.Parser (kirokuCommandParser)+import Kiroku.Cli.Subscription.Status (renderSubscriptionStatusRows, subscriptionStatusRows)+import Kiroku.Store (ConnectionSettings, KirokuStore, defaultConnectionSettings, withStore)+import Kiroku.Store.Subscription (subscriptionStates)+import Options.Applicative (+ Parser,+ ParserInfo,+ auto,+ fullDesc,+ header,+ help,+ helper,+ info,+ long,+ metavar,+ option,+ progDesc,+ strOption,+ value,+ (<**>),+ )++data StandaloneOptions = StandaloneOptions+ { databaseUrl :: !(Maybe Text)+ , schema :: !Text+ , poolSize :: !Int+ , standaloneCommand :: !KirokuCommand+ }+ deriving stock (Eq, Show)++data StandaloneRuntime = StandaloneRuntime+ { settings :: !ConnectionSettings+ , runtimeCommand :: !KirokuCommand+ }++standaloneParserInfo :: ParserInfo StandaloneOptions+standaloneParserInfo =+ info+ (standaloneOptionsParser <**> helper)+ ( fullDesc+ <> progDesc "Run Kiroku operator commands against a store opened by this process. Subscription status reads this process-local live registry."+ <> header "kiroku - operator commands for Kiroku event stores"+ )++standaloneOptionsParser :: Parser StandaloneOptions+standaloneOptionsParser =+ StandaloneOptions+ <$> optional+ ( strOption+ ( long "database-url"+ <> metavar "URL"+ <> help "PostgreSQL connection string. Defaults to KIROKU_DATABASE_URL."+ )+ )+ <*> strOption+ ( long "schema"+ <> metavar "SCHEMA"+ <> value "kiroku"+ <> help "PostgreSQL schema that owns Kiroku objects."+ )+ <*> option+ auto+ ( long "pool-size"+ <> metavar "INT"+ <> value 2+ <> help "Connection pool size for this operator command."+ )+ <*> kirokuCommandParser++resolveStandaloneOptions :: [(String, String)] -> StandaloneOptions -> Either Text StandaloneRuntime+resolveStandaloneOptions env opts+ | poolSize opts <= 0 = Left "kiroku: --pool-size must be greater than zero"+ | otherwise = do+ conn <- case databaseUrl opts <|> (T.pack <$> lookup "KIROKU_DATABASE_URL" env) of+ Just connString | not (T.null connString) -> Right connString+ _ -> Left "kiroku: missing database connection string; pass --database-url or set KIROKU_DATABASE_URL"+ pure+ StandaloneRuntime+ { settings =+ defaultConnectionSettings conn+ & #schema .~ schema opts+ & #poolSize .~ poolSize opts+ , runtimeCommand = standaloneCommand opts+ }++runStandaloneCommand :: StandaloneRuntime -> IO Text+runStandaloneCommand runtime =+ withStore (settings runtime) $ \store ->+ renderStandaloneCommand store (runtimeCommand runtime)++renderStandaloneCommand :: KirokuStore -> KirokuCommand -> IO Text+renderStandaloneCommand _ KirokuNoCommand =+ pure "No Kiroku operator command was selected."+renderStandaloneCommand store (KirokuSubscriptions (SubscriptionStatus (StatusOptions format))) = do+ snapshot <- subscriptionStates store+ let rows = subscriptionStatusRows snapshot+ rendered = renderSubscriptionStatusRows format rows+ pure $+ case (format, rows) of+ (OutputTable, []) ->+ rendered+ <> "No live subscriptions in this process-local registry. Standalone status cannot inspect subscriptions running in another service process."+ _ -> rendered
+ src/Kiroku/Cli/Subscription/Status.hs view
@@ -0,0 +1,93 @@+module Kiroku.Cli.Subscription.Status (+ SubscriptionStatusRow (..),+ renderSubscriptionStatusRows,+ subscriptionStatusRows,+) where++import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as LBS+import Data.Int (Int32, Int64)+import Data.List (sortOn)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Kiroku.Cli.Command (OutputFormat (..))+import Kiroku.Store.Subscription (SubscriptionStateView (..))+import Kiroku.Store.Subscription.Types (SubscriptionName (..))+import Kiroku.Store.Types (GlobalPosition (..))++data SubscriptionStatusRow = SubscriptionStatusRow+ { rowSubscription :: !Text+ , rowMember :: !Int32+ , rowPhase :: !Text+ , rowGlobalPosition :: !Int64+ }+ deriving stock (Eq, Show)++subscriptionStatusRows :: Map (SubscriptionName, Int32) SubscriptionStateView -> [SubscriptionStatusRow]+subscriptionStatusRows =+ sortOn (\row -> (rowSubscription row, rowMember row))+ . fmap toRow+ . Map.elems+ where+ toRow view =+ let SubscriptionName name = subscriptionName view+ GlobalPosition position = cursor view+ in SubscriptionStatusRow+ { rowSubscription = name+ , rowMember = member view+ , rowPhase = statePhase view+ , rowGlobalPosition = position+ }++renderSubscriptionStatusRows :: OutputFormat -> [SubscriptionStatusRow] -> Text+renderSubscriptionStatusRows OutputTable = renderTable+renderSubscriptionStatusRows OutputJson = renderJson++renderTable :: [SubscriptionStatusRow] -> Text+renderTable rows =+ T.unlines (header : fmap renderRow rows)+ where+ header =+ pad subWidth "SUBSCRIPTION"+ <> " "+ <> pad memberWidth "MEMBER"+ <> " "+ <> pad phaseWidth "PHASE"+ <> " GLOBAL_POSITION"+ subWidth = columnWidth "SUBSCRIPTION" (fmap rowSubscription rows)+ memberWidth = columnWidth "MEMBER" (fmap (T.pack . show . rowMember) rows)+ phaseWidth = columnWidth "PHASE" (fmap rowPhase rows)+ renderRow row =+ pad subWidth (rowSubscription row)+ <> " "+ <> pad memberWidth (T.pack (show (rowMember row)))+ <> " "+ <> pad phaseWidth (rowPhase row)+ <> " "+ <> T.pack (show (rowGlobalPosition row))++columnWidth :: Text -> [Text] -> Int+columnWidth label values =+ maximum (T.length label : fmap T.length values)++pad :: Int -> Text -> Text+pad width value =+ value <> T.replicate (width - T.length value) " "++renderJson :: [SubscriptionStatusRow] -> Text+renderJson =+ TE.decodeUtf8+ . LBS.toStrict+ . Aeson.encode+ . fmap rowJson+ where+ rowJson row =+ Aeson.object+ [ "subscription" Aeson..= rowSubscription row+ , "member" Aeson..= rowMember row+ , "phase" Aeson..= rowPhase row+ , "global_position" Aeson..= rowGlobalPosition row+ ]
+ test/Main.hs view
@@ -0,0 +1,265 @@+module Main where++import Control.Concurrent (threadDelay)+import Control.Lens ((^.))+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.Int (Int32, Int64)+import Data.List (isInfixOf)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Kiroku.Cli (KirokuCommand (..), kirokuParserInfo, kirokuSubparser, renderKirokuCommandWithStore)+import Kiroku.Cli.Command (OutputFormat (..), StatusOptions (..), SubscriptionCommand (..))+import Kiroku.Cli.Standalone (StandaloneOptions (..), StandaloneRuntime (..), resolveStandaloneOptions, runStandaloneCommand, standaloneParserInfo)+import Kiroku.Cli.Subscription.Status (SubscriptionStatusRow (..), renderSubscriptionStatusRows, subscriptionStatusRows)+import Kiroku.Store+import Kiroku.Store.Subscription.Fsm (SubscriptionState (..))+import Kiroku.Test.Postgres (withMigratedTestDatabase)+import Options.Applicative (+ Parser,+ ParserInfo,+ ParserResult (..),+ command,+ defaultPrefs,+ execParserPure,+ fullDesc,+ helper,+ info,+ progDesc,+ renderFailure,+ subparser,+ (<**>),+ )+import Test.Hspec++data HostCommand+ = HostOnly+ | HostKiroku KirokuCommand+ deriving stock (Eq, Show)++main :: IO ()+main =+ hspec $ do+ describe "kirokuParserInfo" $ do+ it "renders top-level help" $ do+ let result = execParserPure defaultPrefs kirokuParserInfo ["--help"]+ renderedHelp result `shouldSatisfy` isInfixOf "operator commands for Kiroku"++ it "parses subscriptions status with the default table format" $ do+ case execParserPure defaultPrefs kirokuParserInfo ["subscriptions", "status"] of+ Success parsed ->+ parsed `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable))+ other -> expectationFailure ("expected parser success, got " <> renderedHelp other)++ it "parses subscriptions status with JSON output" $ do+ case execParserPure defaultPrefs kirokuParserInfo ["subscriptions", "status", "--format", "json"] of+ Success parsed ->+ parsed `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson))+ other -> expectationFailure ("expected parser success, got " <> renderedHelp other)++ it "renders status help" $ do+ let result = execParserPure defaultPrefs kirokuParserInfo ["subscriptions", "status", "--help"]+ renderedHelp result `shouldSatisfy` isInfixOf "List live subscription phases"++ describe "kirokuSubparser" $ do+ it "parses as a nested host command" $ do+ case execParserPure defaultPrefs hostParserInfo ["kiroku"] of+ Success parsed -> parsed `shouldBe` HostKiroku KirokuNoCommand+ other -> expectationFailure ("expected parser success, got " <> renderedHelp other)++ it "parses a nested subscription status command" $ do+ case execParserPure defaultPrefs hostParserInfo ["kiroku", "subscriptions", "status", "--format", "json"] of+ Success parsed ->+ parsed `shouldBe` HostKiroku (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson)))+ other -> expectationFailure ("expected parser success, got " <> renderedHelp other)++ it "renders nested help under a host command parser" $ do+ let result = execParserPure defaultPrefs hostParserInfo ["kiroku", "--help"]+ renderedHelp result `shouldSatisfy` isInfixOf "Run Kiroku operator commands."++ describe "standaloneParserInfo" $ do+ it "parses process options separately from the Kiroku command" $ do+ case execParserPure defaultPrefs standaloneParserInfo ["--database-url", "postgres://flag", "--schema", "ops", "--pool-size", "3", "subscriptions", "status", "--format", "json"] of+ Success parsed ->+ parsed+ `shouldBe` StandaloneOptions+ { databaseUrl = Just "postgres://flag"+ , schema = "ops"+ , poolSize = 3+ , standaloneCommand = KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson))+ }+ other -> expectationFailure ("expected parser success, got " <> renderedHelp other)++ it "resolves database URL from the environment when the flag is absent" $ do+ case execParserPure defaultPrefs standaloneParserInfo ["subscriptions", "status"] of+ Success parsed ->+ case resolveStandaloneOptions [("KIROKU_DATABASE_URL", "postgres://env")] parsed of+ Right StandaloneRuntime{settings = settings, runtimeCommand = parsedCommand} -> do+ settings ^. #connString `shouldBe` "postgres://env"+ settings ^. #schema `shouldBe` "kiroku"+ settings ^. #poolSize `shouldBe` 2+ parsedCommand `shouldBe` KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable))+ Left err -> expectationFailure ("expected resolved runtime, got " <> show err)+ other -> expectationFailure ("expected parser success, got " <> renderedHelp other)++ it "lets --database-url override the environment and rejects invalid pool sizes" $ do+ let opts =+ StandaloneOptions+ { databaseUrl = Just "postgres://flag"+ , schema = "ops"+ , poolSize = 0+ , standaloneCommand = KirokuNoCommand+ }+ case resolveStandaloneOptions [("KIROKU_DATABASE_URL", "postgres://env")] opts of+ Left err -> err `shouldBe` "kiroku: --pool-size must be greater than zero"+ Right _ -> expectationFailure "expected invalid pool size to fail"++ it "requires a database URL from either flag or environment" $ do+ let opts =+ StandaloneOptions+ { databaseUrl = Nothing+ , schema = "kiroku"+ , poolSize = 2+ , standaloneCommand = KirokuNoCommand+ }+ case resolveStandaloneOptions [] opts of+ Left err -> err `shouldBe` "kiroku: missing database connection string; pass --database-url or set KIROKU_DATABASE_URL"+ Right _ -> expectationFailure "expected missing database URL to fail"++ describe "subscriptionStatusRows" $ do+ it "sorts rows and extracts public scalar fields" $ do+ subscriptionStatusRows+ ( Map.fromList+ [ ((SubscriptionName "beta", 1), view "beta" 1 "live" 42)+ , ((SubscriptionName "alpha", 0), view "alpha" 0 "catching_up" 7)+ ]+ )+ `shouldBe` [ SubscriptionStatusRow "alpha" 0 "catching_up" 7+ , SubscriptionStatusRow "beta" 1 "live" 42+ ]++ describe "renderSubscriptionStatusRows" $ do+ it "renders a header for an empty table" $ do+ renderSubscriptionStatusRows OutputTable []+ `shouldBe` "SUBSCRIPTION MEMBER PHASE GLOBAL_POSITION\n"++ it "renders table rows" $ do+ renderSubscriptionStatusRows OutputTable [SubscriptionStatusRow "alpha" 0 "live" 12]+ `shouldSatisfy` T.isInfixOf "alpha 0 live 12"++ it "renders script-friendly JSON" $ do+ Aeson.eitherDecodeStrictText (renderSubscriptionStatusRows OutputJson [SubscriptionStatusRow "alpha" 0 "live" 12])+ `shouldBe` Right+ [ Aeson.object+ [ "subscription" Aeson..= ("alpha" :: Text)+ , "member" Aeson..= (0 :: Int)+ , "phase" Aeson..= ("live" :: Text)+ , "global_position" Aeson..= (12 :: Int)+ ]+ ]++ describe "renderKirokuCommandWithStore" $ do+ it "renders status from a live KirokuStore registry" $+ withTestStore $ \store -> do+ let name = SubscriptionName "cli-registry"+ Right _ <- runStoreIO store $ appendToStream (StreamName "cli-registry-1") NoStream [makeEvent "A" (Aeson.object [])]+ handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))+ live <- waitUntilPhase 5_000_000 store (name, 0) "live"+ live `shouldBe` True+ output <-+ renderKirokuCommandWithStore+ store+ (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable)))+ output `shouldSatisfy` T.isInfixOf "cli-registry"+ output `shouldSatisfy` T.isInfixOf "live"+ output `shouldSatisfy` T.isInfixOf "0"+ cancel handle++ it "runs nested Kiroku commands through a host command wrapper" $+ withTestStore $ \store -> do+ output <- runHostCommand store (HostKiroku (KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputJson))))+ output `shouldBe` "[]"++ describe "runStandaloneCommand" $ do+ it "opens a migrated store and reports an empty process-local registry successfully" $+ withMigratedTestDatabase $ \connStr -> do+ let opts =+ StandaloneOptions+ { databaseUrl = Just connStr+ , schema = "kiroku"+ , poolSize = 2+ , standaloneCommand = KirokuSubscriptions (SubscriptionStatus (StatusOptions OutputTable))+ }+ case resolveStandaloneOptions [] opts of+ Left err -> expectationFailure ("expected resolved runtime, got " <> show err)+ Right runtime -> do+ output <- runStandaloneCommand runtime+ output `shouldSatisfy` T.isInfixOf "SUBSCRIPTION"+ output `shouldSatisfy` T.isInfixOf "No live subscriptions in this process-local registry"++hostParserInfo :: ParserInfo HostCommand+hostParserInfo =+ info+ (hostCommandParser <**> helper)+ (fullDesc <> progDesc "Fake host CLI used to prove parser embedding.")++hostCommandParser :: Parser HostCommand+hostCommandParser =+ subparser+ ( command+ "host"+ (info (pure HostOnly) (progDesc "Run a host-only command."))+ <> kirokuSubparser HostKiroku+ )++runHostCommand :: KirokuStore -> HostCommand -> IO Text+runHostCommand _ HostOnly =+ pure "host command"+runHostCommand store (HostKiroku kirokuCommand) =+ renderKirokuCommandWithStore store kirokuCommand++renderedHelp :: ParserResult a -> String+renderedHelp (Failure failure) =+ fst (renderFailure failure "test")+renderedHelp (Success _) =+ ""+renderedHelp (CompletionInvoked _) =+ ""++view :: Text -> Int32 -> Text -> Int64 -> SubscriptionStateView+view name member phase position =+ SubscriptionStateView+ { subscriptionName = SubscriptionName name+ , member = member+ , state = Live (GlobalPosition position)+ , statePhase = phase+ , cursor = GlobalPosition position+ }++withTestStore :: (KirokuStore -> IO ()) -> IO ()+withTestStore action =+ withMigratedTestDatabase $ \connStr ->+ withStore (defaultConnectionSettings connStr) action++makeEvent :: Text -> Aeson.Value -> EventData+makeEvent typ payload =+ EventData+ { eventId = Nothing+ , eventType = EventType typ+ , payload = payload+ , metadata = Nothing+ , causationId = Nothing+ , correlationId = Nothing+ }++waitUntilPhase :: Int -> KirokuStore -> (SubscriptionName, Int32) -> Text -> IO Bool+waitUntilPhase budget store key phase+ | budget <= 0 = pure False+ | otherwise = do+ snapshot <- subscriptionStates store+ case Map.lookup key snapshot of+ Just status | statePhase status == phase -> pure True+ _ -> do+ threadDelay 20_000+ waitUntilPhase (budget - 20_000) store key phase