packages feed

langchain-hs-graph-0.0.5.0: src/Langchain/Graph/Checkpointer.hs

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : Langchain.Graph.Checkpointer
Description : Memory and SQLite state checkpointing implementations
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Provides Checkpointer typeclass, thread-safe MemoryCheckpointer, and persistent SQLiteCheckpointer.
-}
module Langchain.Graph.Checkpointer
  ( Checkpointer (..)
  , MemoryCheckpointer (..)
  , newMemoryCheckpointer
  , SQLiteCheckpointer (..)
  , newSQLiteCheckpointer
  ) where

import Control.Concurrent.STM
import Control.Exception (try)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson (FromJSON, ToJSON, decode, encode)
import qualified Data.ByteString.Lazy as BL
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Database.SQLite.Simple

import Langchain.Core.Error (LangchainError, internalError)
import Langchain.Graph.StateGraph (NodeId)

-- | Effect-polymorphic Checkpointer typeclass
class Checkpointer cp m where
  saveCheckpoint :: ToJSON s => cp -> Text -> NodeId -> s -> m (Either LangchainError ())
  loadCheckpoint :: FromJSON s => cp -> Text -> NodeId -> m (Either LangchainError (Maybe s))

-- | In-memory thread-safe checkpointer using TVar
newtype MemoryCheckpointer = MemoryCheckpointer
  { memStore :: TVar (Map (Text, NodeId) BL.ByteString)
  }

-- | Construct a new MemoryCheckpointer instance
newMemoryCheckpointer :: MonadIO m => m MemoryCheckpointer
newMemoryCheckpointer = liftIO $ do
  var <- newTVarIO Map.empty
  pure $ MemoryCheckpointer var

instance MonadIO m => Checkpointer MemoryCheckpointer m where
  saveCheckpoint cp threadId nodeId state = liftIO $ do
    let bytes = encode state
    atomically $ modifyTVar' (memStore cp) (Map.insert (threadId, nodeId) bytes)
    pure $ Right ()

  loadCheckpoint cp threadId nodeId = liftIO $ do
    store <- readTVarIO (memStore cp)
    case Map.lookup (threadId, nodeId) store of
      Nothing -> pure $ Right Nothing
      Just bytes -> case decode bytes of
        Nothing ->
          pure $ Left $ internalError "Failed to decode state checkpoint" (Just "MemoryCheckpointer") Nothing
        Just s -> pure $ Right (Just s)

-- | SQLite persistent checkpointer using sqlite-simple
newtype SQLiteCheckpointer = SQLiteCheckpointer
  { dbFilePath :: FilePath
  }

-- | Construct a new SQLiteCheckpointer and initialize database table
newSQLiteCheckpointer :: MonadIO m => FilePath -> m SQLiteCheckpointer
newSQLiteCheckpointer path = liftIO $ do
  conn <- open path
  execute_
    conn
    "CREATE TABLE IF NOT EXISTS checkpoints (thread_id TEXT, node_id TEXT, state TEXT, PRIMARY KEY (thread_id, node_id))"
  close conn
  pure $ SQLiteCheckpointer path

instance MonadIO m => Checkpointer SQLiteCheckpointer m where
  saveCheckpoint cp threadId nodeId state = liftIO $ do
    let stateTxt = TE.decodeUtf8 (BL.toStrict $ encode state)
    eRes <- try $ do
      conn <- open (dbFilePath cp)
      execute
        conn
        "INSERT OR REPLACE INTO checkpoints (thread_id, node_id, state) VALUES (?, ?, ?)"
        (threadId, nodeId, stateTxt)
      close conn
    case eRes of
      Left err ->
        pure $ Left $ internalError (T.pack $ show (err :: SQLError)) (Just "SQLiteCheckpointer") Nothing
      Right () -> pure $ Right ()

  loadCheckpoint cp threadId nodeId = liftIO $ do
    eRes <- try $ do
      conn <- open (dbFilePath cp)
      rows <-
        query conn "SELECT state FROM checkpoints WHERE thread_id = ? AND node_id = ?" (threadId, nodeId) ::
          IO [[Text]]
      close conn
      pure rows
    case eRes of
      Left err ->
        pure $ Left $ internalError (T.pack $ show (err :: SQLError)) (Just "SQLiteCheckpointer") Nothing
      Right [[stateTxt]] -> case decode (BL.fromStrict $ TE.encodeUtf8 stateTxt) of
        Nothing ->
          pure $
            Left $
              internalError "Failed to decode SQLite checkpoint JSON" (Just "SQLiteCheckpointer") Nothing
        Just s -> pure $ Right (Just s)
      _ -> pure $ Right Nothing