packages feed

kiroku-cli (empty) → 0.1.0.0

raw patch · 10 files changed

Files

+ 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