packages feed

keiro-0.2.0.0: src/Keiro/ReadModel/Schema.hs

{- | The @keiro_read_models@ registry: schema identity and lifecycle status.

Every read model explicitly registers a row at projection startup recording its 'version', 'shapeHash', and
'ReadModelStatus'. The registry is what lets 'Keiro.ReadModel.runQuery'
refuse to serve a model whose code-side schema has drifted from the table on
disk, or one that is mid-rebuild. The status transitions ('markRebuilding',
'markLive', 'markAbandoned') drive the rebuild workflow in
"Keiro.ReadModel.Rebuild".

All operations run as single-statement 'Hasql.Transaction.Transaction's
against the @keiro_read_models@ table; an unrecognized stored status decodes
to 'UnknownStatus' so callers see the raw database value instead of a silent
fallback.
-}
module Keiro.ReadModel.Schema (
    -- * Metadata
    ReadModelMetadata (..),
    ReadModelStatus (..),

    -- * Registration and lookup
    registerReadModel,
    lookupReadModel,

    -- * Status transitions
    markRebuilding,
    markLive,
    markAbandoned,
    transitionReadModelTx,
)
where

import Contravariant.Extras (contrazip3, contrazip4)
import Effectful (Eff, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Prelude
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Transaction (runTransaction)
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude qualified

{- | Lifecycle status of a registered read model.

* 'Live' — current and queryable.
* 'Rebuilding' — being repopulated from the event log; not yet queryable.
* 'Paused' — temporarily not served.
* 'Abandoned' — a rebuild that was given up on.
* 'UnknownStatus' — a database value this library version does not recognize.
-}
data ReadModelStatus
    = Live
    | Rebuilding
    | Paused
    | Abandoned
    | UnknownStatus !Text
    deriving stock (Generic, Eq, Show)

{- | One row of the @keiro_read_models@ registry: a model's name, schema
identity ('version' and 'shapeHash'), the time it was last (re)built, and
its current 'status'.
-}
data ReadModelMetadata = ReadModelMetadata
    { name :: !Text
    , version :: !Int
    , shapeHash :: !Text
    , lastBuiltAt :: !(Maybe UTCTime)
    , status :: !ReadModelStatus
    }
    deriving stock (Generic, Eq, Show)

{- | Register a read model once at projection startup, inserting a 'Live' row
if none exists. Querying never performs this registration automatically, so
deployments that previously relied on their first query to register must add
this startup call. Idempotent:
an existing registration is returned unchanged (the @version@ and
@shapeHash@ are /not/ overwritten), so a query can compare them and detect
schema drift.
-}
registerReadModel :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata
registerReadModel name version shapeHash =
    runTransaction
        $ Tx.statement
            (name, Prelude.fromIntegral version, shapeHash)
            registerReadModelStmt

-- | Look up a read model's registry row by name, if it exists.
lookupReadModel :: (Store :> es) => Text -> Eff es (Maybe ReadModelMetadata)
lookupReadModel name =
    runTransaction
        $ Tx.statement name lookupReadModelStmt

{- | Upsert the registry row to 'Rebuilding' at the given schema identity.
Marks the model as being repopulated so queries stop serving it until
'markLive'.
-}
markRebuilding :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata
markRebuilding name version shapeHash =
    runTransaction $ transitionReadModelTx name version shapeHash Rebuilding

{- | Upsert the registry row to 'Live' at the given schema identity, stamping
@last_built_at@. Makes the model queryable again after a rebuild.
-}
markLive :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata
markLive name version shapeHash =
    runTransaction $ transitionReadModelTx name version shapeHash Live

{- | Upsert the registry row to 'Abandoned' at the given schema identity,
recording that a rebuild was given up on.
-}
markAbandoned :: (Store :> es) => Text -> Int -> Text -> Eff es ReadModelMetadata
markAbandoned name version shapeHash =
    runTransaction $ transitionReadModelTx name version shapeHash Abandoned

{- | Transaction-composable form of a registry status transition. Rebuild
orchestration uses this form so the status row lock, table reset, dedup reset,
and checkpoint reset share one database transaction.
-}
transitionReadModelTx :: Text -> Int -> Text -> ReadModelStatus -> Tx.Transaction ReadModelMetadata
transitionReadModelTx name version shapeHash status =
    Tx.statement
        (name, Prelude.fromIntegral version, shapeHash, statusToText status)
        transitionReadModelStmt

registerReadModelStmt :: Statement (Text, Int64, Text) ReadModelMetadata
registerReadModelStmt =
    preparable
        """
        INSERT INTO keiro.keiro_read_models (name, version, shape_hash, status, last_built_at)
        VALUES ($1, $2, $3, 'live', now())
        ON CONFLICT (name) DO UPDATE
          SET name = EXCLUDED.name
        RETURNING name, version, shape_hash, last_built_at, status
        """
        ( contrazip3
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.int8))
            (E.param (E.nonNullable E.text))
        )
        readModelMetadataSingle

lookupReadModelStmt :: Statement Text (Maybe ReadModelMetadata)
lookupReadModelStmt =
    preparable
        """
        SELECT name, version, shape_hash, last_built_at, status
        FROM keiro.keiro_read_models
        WHERE name = $1
        """
        (E.param (E.nonNullable E.text))
        (D.rowMaybe readModelMetadataDecoder)

transitionReadModelStmt :: Statement (Text, Int64, Text, Text) ReadModelMetadata
transitionReadModelStmt =
    preparable
        """
        INSERT INTO keiro.keiro_read_models (name, version, shape_hash, status, last_built_at, updated_at)
        VALUES ($1, $2, $3, $4, now(), now())
        ON CONFLICT (name) DO UPDATE
          SET version = EXCLUDED.version,
              shape_hash = EXCLUDED.shape_hash,
              status = EXCLUDED.status,
              last_built_at = CASE
                WHEN EXCLUDED.status = 'live' THEN now()
                ELSE keiro_read_models.last_built_at
              END,
              updated_at = now()
        RETURNING name, version, shape_hash, last_built_at, status
        """
        ( contrazip4
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.int8))
            (E.param (E.nonNullable E.text))
            (E.param (E.nonNullable E.text))
        )
        readModelMetadataSingle

readModelMetadataSingle :: D.Result ReadModelMetadata
readModelMetadataSingle =
    D.singleRow readModelMetadataDecoder

readModelMetadataDecoder :: D.Row ReadModelMetadata
readModelMetadataDecoder =
    ReadModelMetadata
        <$> D.column (D.nonNullable D.text)
        <*> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))
        <*> D.column (D.nonNullable D.text)
        <*> D.column (D.nullable D.timestamptz)
        <*> (statusFromText <$> D.column (D.nonNullable D.text))

statusToText :: ReadModelStatus -> Text
statusToText = \case
    Live -> "live"
    Rebuilding -> "rebuilding"
    Paused -> "paused"
    Abandoned -> "abandoned"
    UnknownStatus raw -> raw

statusFromText :: Text -> ReadModelStatus
statusFromText = \case
    "live" -> Live
    "rebuilding" -> Rebuilding
    "paused" -> Paused
    "abandoned" -> Abandoned
    raw -> UnknownStatus raw