packages feed

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 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))