plexus-protocol-0.3.0.0: src/Plexus/Schema/Cache.hs
-- | Schema caching for the dynamic CLI
--
-- Caches the PlexusSchema and enriched schemas to disk to avoid fetching on every invocation.
module Plexus.Schema.Cache
( -- * Types
CachedSchema(..)
, CacheConfig(..)
-- * Cache Operations
, defaultCacheConfig
, defaultCachePath
, loadCache
, saveCache
, isFresh
, loadSchemaWithCache
-- * Enriched Schema Lookup
, lookupMethodSchema
) where
import Control.Exception (SomeException, catch)
import Data.Aeson
import qualified Data.ByteString.Lazy as LBS
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
import System.Directory (createDirectoryIfMissing, doesFileExist, getXdgDirectory, XdgDirectory(..))
import System.FilePath ((</>), takeDirectory)
import qualified Plexus.Schema
import Plexus.Schema (PlexusSchema(..), ActivationInfo(..), EnrichedSchema, ActivationFullSchema(..), MethodSchemaInfo(..), MethodSchema(..), parseMethodSchemas, PlexusHash(..), PlexusHashEvent(..), extractHashEvent)
import Plexus.Types (PlexusStreamItem)
-- ============================================================================
-- Types
-- ============================================================================
-- | Cached schema with metadata
data CachedSchema = CachedSchema
{ cachedHash :: Text -- ^ Plexus hash for invalidation
, cachedSchema :: PlexusSchema -- ^ The cached plexus schema
, cachedFullSchemas :: Map Text ActivationFullSchema -- ^ Full schemas by namespace
}
deriving stock (Show, Eq, Generic)
instance FromJSON CachedSchema where
parseJSON = withObject "CachedSchema" $ \o ->
CachedSchema
<$> o .: "hash"
<*> o .: "schema"
<*> o .:? "full_schemas" .!= Map.empty
instance ToJSON CachedSchema where
toJSON CachedSchema{..} = object
[ "hash" .= cachedHash
, "schema" .= cachedSchema
, "full_schemas" .= cachedFullSchemas
]
-- | Cache configuration
data CacheConfig = CacheConfig
{ cachePath :: FilePath -- ^ Path to cache file
}
deriving stock (Show, Eq)
-- ============================================================================
-- Configuration
-- ============================================================================
-- | Default cache configuration
-- Uses ~/.cache/symbols/schema.json on Linux/macOS
defaultCacheConfig :: IO CacheConfig
defaultCacheConfig = do
path <- defaultCachePath
pure CacheConfig
{ cachePath = path
}
-- | Get the default cache file path
-- Respects XDG_CACHE_HOME on Linux/macOS
defaultCachePath :: IO FilePath
defaultCachePath = do
cacheDir <- getXdgDirectory XdgCache "symbols"
pure $ cacheDir </> "schema.json"
-- ============================================================================
-- Cache Operations
-- ============================================================================
-- | Load cached schema from disk
loadCache :: FilePath -> IO (Maybe CachedSchema)
loadCache path = do
exists <- doesFileExist path
if not exists
then pure Nothing
else do
result <- (Just <$> LBS.readFile path) `catch` \(_ :: SomeException) -> pure Nothing
case result of
Nothing -> pure Nothing
Just bs -> case eitherDecode bs of
Left _err -> pure Nothing
Right cached -> pure (Just cached)
-- | Save schema to cache
saveCache :: FilePath -> CachedSchema -> IO ()
saveCache path cached = do
createDirectoryIfMissing True (takeDirectory path)
LBS.writeFile path (encode cached)
`catch` \(_ :: SomeException) -> pure () -- Silently fail on write errors
-- | Check if a cached schema is still fresh (hash matches current hash)
isFresh :: Text -> CachedSchema -> Bool
isFresh currentHash cached = cachedHash cached == currentHash
-- | Load schema with caching
--
-- 1. If forceRefresh is True, skip cache and fetch fresh
-- 2. Otherwise, fetch current hash and compare with cached hash
-- 3. If hashes match, return cached schema
-- 4. Otherwise, fetch fresh schema and cache it with new hash
loadSchemaWithCache
:: Bool -- ^ Force refresh (--refresh flag)
-> CacheConfig -- ^ Cache configuration
-> IO (Either Text Text) -- ^ Fetch plexus hash
-> IO (Either Text PlexusSchema) -- ^ Fetch plexus schema
-> (Text -> IO (Maybe ActivationFullSchema)) -- ^ Fetch full schema for a namespace
-> IO (Either Text CachedSchema)
loadSchemaWithCache forceRefresh config fetchHash fetchSchema fetchFullSchema = do
if forceRefresh
then fetchAndCache
else do
-- Fetch current hash
hashResult <- fetchHash
case hashResult of
Left err -> do
-- If we can't get hash, try to use cache anyway
mCached <- loadCache (cachePath config)
case mCached of
Just cached -> pure $ Right cached
Nothing -> pure $ Left err
Right currentHash -> do
-- Load cached schema
mCached <- loadCache (cachePath config)
case mCached of
Nothing -> fetchAndCacheWithHash currentHash
Just cached ->
if isFresh currentHash cached
then pure $ Right cached
else fetchAndCacheWithHash currentHash
where
fetchAndCache = do
hashResult <- fetchHash
case hashResult of
Left err -> pure $ Left err
Right hash -> fetchAndCacheWithHash hash
fetchAndCacheWithHash hash = do
result <- fetchSchema
case result of
Left err -> do
-- On fetch failure, try stale cache as fallback
mCached <- loadCache (cachePath config)
case mCached of
Just cached -> pure $ Right cached -- Use stale cache
Nothing -> pure $ Left err -- No cache available
Right schema -> do
-- Fetch full schemas for all activations
fullSchemas <- fetchAllFullSchemas schema
let cached = CachedSchema
{ cachedHash = hash
, cachedSchema = schema
, cachedFullSchemas = fullSchemas
}
saveCache (cachePath config) cached
pure $ Right cached
fetchAllFullSchemas schema = do
let namespaces = map activationNamespace (schemaActivations schema)
pairs <- mapM fetchPair namespaces
pure $ Map.fromList [(ns, fs) | (ns, Just fs) <- pairs]
fetchPair ns = do
mSchema <- fetchFullSchema ns
pure (ns, mSchema)
-- ============================================================================
-- Full Schema Lookup
-- ============================================================================
-- | Look up a method schema from the cached full schemas
-- Given "arbor_tree_create", looks up "arbor" full schema and finds "tree_create" method
lookupMethodSchema :: CachedSchema -> Text -> Maybe MethodSchemaInfo
lookupMethodSchema cached fullMethod = do
-- Split "arbor_tree_create" into ("arbor", "tree_create")
let (ns, method) = splitMethod fullMethod
-- Look up the full schema for this namespace
fullSchema <- Map.lookup ns (cachedFullSchemas cached)
-- Find the matching method
findMethod method (fullSchemaMethods fullSchema)
-- | Split "namespace_method" into (namespace, method)
-- e.g., "arbor_tree_create" -> ("arbor", "tree_create")
splitMethod :: Text -> (Text, Text)
splitMethod full =
case T.break (== '_') full of
(ns, rest) | not (T.null rest) -> (ns, T.drop 1 rest)
_ -> (full, "")
-- | Find a method by name in a list of method schema infos
findMethod :: Text -> [MethodSchemaInfo] -> Maybe MethodSchemaInfo
findMethod name = find (\m -> methodInfoName m == name)
where
find f = foldr (\x acc -> if f x then Just x else acc) Nothing