kioku-core-0.7.0.0: src/Kioku/ReadModel.hs
-- | The schema identities of every Kioku read model registered in the
-- @keiro_read_models@ registry.
--
-- Kioku evolves its projections with /additive/ migrations: new columns are
-- added and backfill with sensible defaults, so existing rows remain correct
-- for the newer read-model version without a full offline rebuild. When such a
-- migration ships, the code-declared 'Keiro.ReadModel.version' /
-- 'Keiro.ReadModel.shapeHash' advance, but any pre-existing
-- @keiro_read_models@ row stays pinned at the old identity —
-- 'Keiro.ReadModel.registerReadModel' only inserts, it never bumps an existing
-- row. Every subsequent query then fails closed with
-- 'Keiro.ReadModel.ReadModelStaleSchema'.
--
-- 'reconcileReadModelRegistry' repairs those rows to the identity the compiled
-- code expects. 'kiokuReadModelSchemas' is derived from the same validated
-- projection catalog that registers and supplies the queries, so the migration
-- reconciler cannot drift into a second runtime inventory. The @kioku-migrate@ executable runs
-- it immediately after applying migrations, which is why a read-model version
-- bump needs no hand-written registry SQL. A host that applies migrations as a
-- library (by running @Kioku.Migrations.kiokuMigrationPlan@ through pg-migrate)
-- must call it itself; see @docs\/user\/library-api.md@.
--
-- Because Kioku guarantees its read-model migrations leave the table data correct
-- for the current version, advancing the registry guard is safe here without a
-- rebuild. A consumer that introduces a /non-additive/ reshape must instead
-- rewrite the projected data in its migration before reconciling.
module Kioku.ReadModel
( ReadModelSchema (..),
kiokuReadModelSchemas,
registerKiokuReadModels,
ReconcileOutcome (..),
reconcileReadModelRegistry,
)
where
import Effectful (Eff, (:>))
import Keiro.Projection.Catalog (CatalogRegistration (..), catalogRegistrations)
import Keiro.ReadModel.Rebuild (CatalogRegistrationError, GroupRebuildMetadata, registerProjectionCatalog)
import Keiro.ReadModel.Schema qualified as Schema
import Kioku.Prelude
import Kioku.ProjectionCatalog (kiokuProjectionCatalog)
import Kiroku.Store.Effect (Store)
-- | The registry identity of a read model: its logical name plus the schema
-- 'version' and 'shapeHash' the current code expects.
data ReadModelSchema = ReadModelSchema
{ readModelName :: !Text,
readModelVersion :: !Int,
readModelShapeHash :: !Text
}
deriving stock (Eq, Show)
schemaOf :: CatalogRegistration -> ReadModelSchema
schemaOf registration =
ReadModelSchema registration.registryName registration.version registration.shapeHash
-- | Every Kioku read model paired with the schema identity the current code
-- expects. Ordered session models first, then memory models.
kiokuReadModelSchemas :: [ReadModelSchema]
kiokuReadModelSchemas = schemaOf <$> catalogRegistrations kiokuProjectionCatalog
-- | Register Kioku's validated projection catalog at application startup.
--
-- This registers the query identities, rebuild groups, and persisted catalog
-- fingerprint in one idempotent handshake. A conflicting fingerprint or stale
-- query identity is returned as a typed registration error.
registerKiokuReadModels :: (Store :> es) => Eff es (Either CatalogRegistrationError [GroupRebuildMetadata])
registerKiokuReadModels = registerProjectionCatalog kiokuProjectionCatalog
-- | What reconciliation did to one read model's registry row.
data ReconcileOutcome
= -- | No row existed; one was inserted at the current identity.
Registered
| -- | The row disagreed with the code and was bumped to the current identity.
Reconciled
| -- | The row already matched the code. Left untouched, status and all.
AlreadyCurrent
deriving stock (Eq, Show)
-- | Bring every row of keiro's @keiro_read_models@ registry up to the schema
-- identity the compiled code expects, and report what changed.
--
-- Idempotent: a second run reports 'AlreadyCurrent' for every model and writes
-- nothing. It is built on keiro's own registry statements, which are compiled
-- into whichever keiro version Kioku links against — so it finds the registry
-- table wherever that version puts it, and survives keiro's schema relocation
-- without change.
reconcileReadModelRegistry ::
(Store :> es) => Eff es [(ReadModelSchema, ReconcileOutcome)]
reconcileReadModelRegistry =
forM kiokuReadModelSchemas \schema -> do
existing <- Schema.lookupReadModel schema.readModelName
outcome <- case existing of
Nothing ->
Registered
<$ Schema.registerReadModel
schema.readModelName
schema.readModelVersion
schema.readModelShapeHash
Just metadata
| metadata.version == schema.readModelVersion
&& metadata.shapeHash == schema.readModelShapeHash ->
pure AlreadyCurrent
| otherwise ->
Reconciled
<$ Schema.markLive
schema.readModelName
schema.readModelVersion
schema.readModelShapeHash
pure (schema, outcome)