keiki 0.3.0.0 → 0.3.1.0
raw patch · 4 files changed
+175/−10 lines, 4 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
+ Keiki.Shape: ($dmstateShapeCanonical) :: (CanonicalStateShape a, GStateShape (Rep a)) => Proxy a -> Text
+ Keiki.Shape: class CanonicalStateShape a
+ Keiki.Shape: instance (GHC.Internal.Generics.Constructor c, Keiki.Shape.GStateShape f) => Keiki.Shape.GStateShape (GHC.Internal.Generics.M1 GHC.Internal.Generics.C c f)
+ Keiki.Shape: instance (GHC.Internal.Generics.Datatype d, Keiki.Shape.GStateShape f) => Keiki.Shape.GStateShape (GHC.Internal.Generics.M1 GHC.Internal.Generics.D d f)
+ Keiki.Shape: instance (Keiki.Shape.GStateShape left, Keiki.Shape.GStateShape right) => Keiki.Shape.GStateShape (left GHC.Internal.Generics.:*: right)
+ Keiki.Shape: instance (Keiki.Shape.GStateShape left, Keiki.Shape.GStateShape right) => Keiki.Shape.GStateShape (left GHC.Internal.Generics.:+: right)
+ Keiki.Shape: instance Keiki.Shape.CanonicalTypeName field => Keiki.Shape.GStateShape (GHC.Internal.Generics.K1 i field)
+ Keiki.Shape: instance Keiki.Shape.GStateShape GHC.Internal.Generics.U1
+ Keiki.Shape: instance Keiki.Shape.GStateShape f => Keiki.Shape.GStateShape (GHC.Internal.Generics.M1 GHC.Internal.Generics.S s f)
+ Keiki.Shape: stateShapeCanonical :: CanonicalStateShape a => Proxy a -> Text
+ Keiki.Shape: stateShapeHash :: CanonicalStateShape a => Proxy a -> Text
Files
- CHANGELOG.md +11/−0
- keiki.cabal +1/−1
- src/Keiki/Shape.hs +93/−8
- test/Keiki/ShapeSpec.hs +70/−1
CHANGELOG.md view
@@ -9,6 +9,17 @@ ## [Unreleased] +## [0.3.1.0] — 2026-07-23++### Added++- `Keiki.Shape.CanonicalStateShape`, `stateShapeCanonical`, and+ `stateShapeHash` provide a generic, deterministic control-state discriminator+ for snapshot compatibility. The canonical form records datatype and+ constructor identity plus field type names; semantic fold changes still need+ an explicit version or fold fingerprint.++ ## [0.3.0.0] — 2026-07-23 ### Added
keiki.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: keiki-version: 0.3.0.0+version: 0.3.1.0 synopsis: Pure core for symbolic-register transducer event sourcing. description: A Haskell library for the pure core of event sourcing, workflow
src/Keiki/Shape.hs view
@@ -1,13 +1,14 @@ {-# LANGUAGE DefaultSignatures #-} --- | The /shape hash/ for @RegFile rs@.+-- | Shape hashes for control states and @RegFile rs@ values. -- -- A snapshot persister (see keiro's @StateCodec (s, RegFile rs)@) needs a--- compact discriminator for the type-level slot list. 'regFileShapeHash'--- provides it: a SHA-256 of a canonical, deterministic rendering of every--- slot's name and type. Built-in scalar and container names are pinned to--- Haskell-source spellings, so GHC's internal module reorganizations do not--- invalidate their hashes.+-- compact discriminator for both halves of the persisted state.+-- 'stateShapeHash' hashes a generic rendering of the control-state datatype;+-- 'regFileShapeHash' hashes a canonical rendering of the type-level slot+-- list. Built-in scalar and container names are pinned to Haskell-source+-- spellings, so GHC's internal module reorganizations do not invalidate their+-- hashes. -- -- The hash is sensitive to structural changes (slot rename / addition / -- removal / reordering / type change) and insensitive to incidental@@ -26,7 +27,11 @@ -- of the snapshot story: the hash discriminates eligible snapshots; the -- codec serialises the eligible ones. module Keiki.Shape- ( -- * Shape hash+ ( -- * Control-state shape hash+ CanonicalStateShape (..),+ stateShapeHash,++ -- * Register-file shape hash KnownRegFileShape (..), regFileShapeHash, @@ -46,6 +51,7 @@ import Data.ByteString qualified as BS import Data.Char (intToDigit) import Data.Int (Int16, Int32, Int64, Int8)+import Data.Kind (Type) import Data.Proxy (Proxy (..)) import Data.Text (Text) import Data.Text qualified as T@@ -54,6 +60,7 @@ import Data.Time.Clock (UTCTime) import Data.Typeable (Typeable) import Data.Word (Word16, Word32, Word64, Word8)+import GHC.Generics qualified as G import GHC.TypeLits (KnownSymbol, symbolVal) import Keiki.Core (Slot) import Type.Reflection@@ -190,7 +197,85 @@ T.pack ")" ] --- * Shape hash --------------------------------------------------------------+-- * Control-state shape hash ------------------------------------------------++-- | Canonical structural identity for a control-state datatype.+--+-- The generic default records the datatype name, constructor names and order,+-- and each constructor field's 'CanonicalTypeName'. It intentionally does not+-- record field names, values, typeclass instances, function bodies, guards,+-- updates, or any other runtime semantics. A snapshot consumer must therefore+-- use a separate version or fold fingerprint for semantic changes that this+-- structural description cannot see.+--+-- Application datatypes normally opt in with an empty instance:+--+-- > data State = Pending | Complete Text+-- > deriving stock (Generic)+-- >+-- > instance CanonicalStateShape State+class CanonicalStateShape a where+ -- | Canonical pre-hash rendering of the control-state datatype.+ stateShapeCanonical :: Proxy a -> Text+ default stateShapeCanonical ::+ (GStateShape (G.Rep a)) =>+ Proxy a ->+ Text+ stateShapeCanonical _ = gStateShapeCanonical (Proxy @(G.Rep a))++-- | Generic rendering used by the 'CanonicalStateShape' default.+class GStateShape (f :: Type -> Type) where+ gStateShapeCanonical :: Proxy f -> Text++instance (G.Datatype d, GStateShape f) => GStateShape (G.M1 G.D d f) where+ gStateShapeCanonical _ =+ T.concat+ [ T.pack "state:",+ T.pack (G.datatypeName (undefined :: G.M1 G.D d f ())),+ T.pack "{",+ gStateShapeCanonical (Proxy @f),+ T.pack "}"+ ]++instance (G.Constructor c, GStateShape f) => GStateShape (G.M1 G.C c f) where+ gStateShapeCanonical _ =+ let constructor = T.pack (G.conName (undefined :: G.M1 G.C c f ()))+ fields = gStateShapeCanonical (Proxy @f)+ in if T.null fields+ then constructor+ else T.concat [constructor, T.pack "(", fields, T.pack ")"]++instance (GStateShape f) => GStateShape (G.M1 G.S s f) where+ gStateShapeCanonical _ = gStateShapeCanonical (Proxy @f)++instance (GStateShape left, GStateShape right) => GStateShape (left G.:+: right) where+ gStateShapeCanonical _ =+ T.concat+ [ gStateShapeCanonical (Proxy @left),+ T.pack "|",+ gStateShapeCanonical (Proxy @right)+ ]++instance (GStateShape left, GStateShape right) => GStateShape (left G.:*: right) where+ gStateShapeCanonical _ =+ T.concat+ [ gStateShapeCanonical (Proxy @left),+ T.pack ",",+ gStateShapeCanonical (Proxy @right)+ ]++instance GStateShape G.U1 where+ gStateShapeCanonical _ = T.empty++instance (CanonicalTypeName field) => GStateShape (G.K1 i field) where+ gStateShapeCanonical _ = canonicalTypeName (Proxy @field)++-- | Shape hash of a control-state datatype, as lower-case hexadecimal+-- SHA-256 over the UTF-8 bytes of 'stateShapeCanonical'. Pure, no 'IO'.+stateShapeHash :: forall a. (CanonicalStateShape a) => Proxy a -> Text+stateShapeHash p = sha256Hex (stateShapeCanonical p)++-- * Register-file shape hash ------------------------------------------------- -- | The class governing slot-lists that carry a shape hash. The -- inductive method 'regFileShapeCanonical' assembles the pre-hash
test/Keiki/ShapeSpec.hs view
@@ -13,13 +13,16 @@ import Data.Time.Calendar (Day) import Data.Time.Clock (UTCTime) import Data.Word (Word16, Word32, Word64, Word8)+import GHC.Generics (Generic) import GHC.TypeLits (Symbol) import Keiki.Shape- ( CanonicalTypeName (..),+ ( CanonicalStateShape (..),+ CanonicalTypeName (..), regFileShapeCanonical, regFileShapeHash, renderStableTypeRep, sha256Hex,+ stateShapeHash, ) import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy) import Type.Reflection (someTypeRep)@@ -29,6 +32,45 @@ instance CanonicalTypeName ApplicationType where canonicalTypeName _ = T.pack "ApplicationType-v1" +data LifecycleState+ = Pending+ | Active+ | Complete+ deriving stock (Generic)++instance CanonicalStateShape LifecycleState++data LifecycleStateWithoutComplete+ = PendingWithoutComplete+ | ActiveWithoutComplete+ deriving stock (Generic)++instance CanonicalStateShape LifecycleStateWithoutComplete++data LifecycleStateWithRenamedConstructor+ = Waiting+ | ActiveRenamed+ | CompleteRenamed+ deriving stock (Generic)++instance CanonicalStateShape LifecycleStateWithRenamedConstructor++data RecordStateInt = RecordStateInt+ { recordCountInt :: Int,+ recordNoteInt :: Maybe Text+ }+ deriving stock (Generic)++instance CanonicalStateShape RecordStateInt++data RecordStateText = RecordStateText+ { recordCountText :: Text,+ recordNoteText :: Maybe Text+ }+ deriving stock (Generic)++instance CanonicalStateShape RecordStateText+ type BuiltInSlots = '[ '("unit", ()), '("bool", Bool),@@ -58,6 +100,33 @@ spec :: Spec spec = do+ describe "stateShapeCanonical" $ do+ it "records datatype name, constructor names, and declaration order" $+ stateShapeCanonical (Proxy @LifecycleState)+ `shouldBe` T.pack "state:LifecycleState{Pending|Active|Complete}"++ it "records constructor field types but not record field names" $+ stateShapeCanonical (Proxy @RecordStateInt)+ `shouldBe` T.pack "state:RecordStateInt{RecordStateInt(Int,Maybe(Text))}"++ describe "stateShapeHash" $ do+ it "changes when an enum constructor is removed" $+ stateShapeHash (Proxy @LifecycleState)+ `shouldSatisfy` (/= stateShapeHash (Proxy @LifecycleStateWithoutComplete))++ it "changes when an enum constructor is renamed" $+ stateShapeHash (Proxy @LifecycleState)+ `shouldSatisfy` (/= stateShapeHash (Proxy @LifecycleStateWithRenamedConstructor))++ it "changes when a record field type changes" $+ stateShapeHash (Proxy @RecordStateInt)+ `shouldSatisfy` (/= stateShapeHash (Proxy @RecordStateText))++ it "is stable and matches its sha256Hex-of-canonical definition" $ do+ let p = Proxy @LifecycleState+ stateShapeHash p `shouldBe` stateShapeHash p+ stateShapeHash p `shouldBe` sha256Hex (stateShapeCanonical p)+ describe "renderStableTypeRep" $ do it "renders Int as GHC.Types.Int (no module path drift on 9.12.*)" $ renderStableTypeRep (someTypeRep (Proxy @Int))