Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bindings/haskell/event-sorcery.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ library
EventSorcery.Engine
EventSorcery.Engine.Internal.FFI
EventSorcery.Job
EventSorcery.Snapshot
EventSorcery.Store
EventSorcery.Stream

Expand Down
5 changes: 4 additions & 1 deletion bindings/haskell/src/EventSorcery/Engine/Internal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,11 @@ supportedAbiMajor = 0


-- | The oldest ABI minor version whose calls this binding can make.
--
-- The snapshot entry points this binding issues arrive in minor 4, so an
-- engine below that floor cannot serve every call the binding makes.
minimumAbiMinor :: Word32
minimumAbiMinor = 3
minimumAbiMinor = 4


-- | Decides whether a packed engine ABI version can serve this binding.
Expand Down
17 changes: 17 additions & 0 deletions bindings/haskell/src/EventSorcery/Engine/Internal/FFI.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ module EventSorcery.Engine.Internal.FFI (
esJobRetry,
esLoadStream,
esOpen,
esSnapshotDiscard,
esSnapshotLoad,
esSnapshotStore,
) where

import Data.Word (Word32, Word64, Word8)
Expand Down Expand Up @@ -93,6 +96,20 @@ foreign import capi safe "event_sorcery.h es_commit_with_job"
esCommitWithJob :: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_snapshot_load"
esSnapshotLoad
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_snapshot_store"
esSnapshotStore
:: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr Word64 -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_snapshot_discard"
esSnapshotDiscard :: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> IO CInt


foreign import capi safe "event_sorcery.h es_job_enqueue"
esJobEnqueue :: Ptr (Ptr EsStore) -> Ptr EsBuf -> Ptr EsBuf -> IO CInt

Expand Down
198 changes: 198 additions & 0 deletions bindings/haskell/src/EventSorcery/Snapshot.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
module EventSorcery.Snapshot (
SnapshotVersion (..),
SnapshotWrite,
StoredSnapshot (..),
discardSnapshot,
loadSnapshot,
snapshotWrite,
storeSnapshot,
) where

import Codec.CBOR.Decoding (
Decoder,
TokenType (TypeNull),
decodeBytes,
decodeListLen,
decodeNull,
decodeWord,
decodeWord64,
peekTokenType,
)
import Codec.CBOR.Encoding (
encodeBytes,
encodeListLen,
encodeString,
encodeWord,
encodeWord64,
)
import Codec.CBOR.Read (deserialiseFromBytes)
import Codec.CBOR.Write (toStrictByteString)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Unrestricted.Linear (Ur (Ur))
import Data.Word (Word64)
import EventSorcery.Engine (EngineError)
import EventSorcery.Engine qualified as Engine
import EventSorcery.Engine.Internal (
callWithOutput,
callWithoutOutput,
decodeResponse,
withInputBuffer,
withOpenStore,
)
import EventSorcery.Engine.Internal.FFI (
esSnapshotDiscard,
esSnapshotLoad,
esSnapshotStore,
)
import EventSorcery.Stream (
AggregateId (AggregateId),
AggregateType (AggregateType),
StreamIdentity (StreamIdentity),
)
import Foreign.Marshal.Alloc (alloca)
import Foreign.Storable (peek, poke)
import Prelude (
Either (..),
Eq,
IO,
Int,
Maybe (..),
Show,
String,
fail,
otherwise,
pure,
show,
($),
(.),
(<$),
(<$>),
(<>),
(==),
(>>=),
)


newtype SnapshotVersion = SnapshotVersion Word64
deriving stock (Eq, Show)


data StoredSnapshot = StoredSnapshot Word64 SnapshotVersion ByteString
deriving stock (Eq, Show)
Comment on lines +81 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use named fields for StoredSnapshot to remove the positional swap hazard.

StoredSnapshot Word64 SnapshotVersion ByteString carries a sequence and a version next to each other. The sequence stays a bare Word64 while the version is a newtype. A caller that swaps the two numeric concepts gets no type error once both are unwrapped.

EventSorcery.Stream.ProposedEvent uses record syntax, and the package enables NoFieldSelectors and OverloadedRecordDot. Follow that convention here. Consider a StreamSequence newtype as well, so the sequence and the version cannot be confused at any call site.

♻️ Proposed record form
-data StoredSnapshot = StoredSnapshot Word64 SnapshotVersion ByteString
-  deriving stock (Eq, Show)
+data StoredSnapshot = StoredSnapshot
+  { sequence :: Word64
+  , version :: SnapshotVersion
+  , payload :: ByteString
+  }
+  deriving stock (Eq, Show)

EventSorcery.Store.resumeSnapshot at line 200 then binds by field name instead of by position.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
data StoredSnapshot = StoredSnapshot Word64 SnapshotVersion ByteString
deriving stock (Eq, Show)
data StoredSnapshot = StoredSnapshot
{ sequence :: Word64
, version :: SnapshotVersion
, payload :: ByteString
}
deriving stock (Eq, Show)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bindings/haskell/src/EventSorcery/Snapshot.hs` around lines 81 - 82, Change
StoredSnapshot to a named-field record, introducing or reusing a StreamSequence
newtype for the sequence alongside SnapshotVersion, and update its construction
and access sites to use record fields. In EventSorcery.Store.resumeSnapshot,
replace positional binding with field-name access while preserving the existing
snapshot behavior.

Source: Path instructions



data SnapshotWrite where
SnapshotWrite
:: Ur (StreamIdentity, Word64, ByteString)
%1 -> SnapshotWrite


snapshotWrite :: StreamIdentity -> Word64 -> ByteString -> SnapshotWrite
snapshotWrite identity sequence payload =
SnapshotWrite (Ur (identity, sequence, payload))
Comment on lines +85 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

The linear SnapshotWrite wrapper over Ur gives no linearity guarantee.

SnapshotWrite takes its payload linearly, but the payload is Ur (StreamIdentity, Word64, ByteString). Ur marks the contents as unrestricted, so pattern matching at line 111 releases them into an unrestricted context immediately. The only invariant enforced is that the SnapshotWrite token is consumed once, and snapshotWrite constructs that token freely from unrestricted arguments. No resource is protected.

Two consistent options:

  1. Drop the linear machinery. Give storeSnapshot a plain StreamIdentity -> Word64 -> ByteString -> IO (Either EngineError SnapshotVersion) signature. This removes the GADT, the Ur import, and the indirection.
  2. Keep linearity, but make it mean something. Hold the payload linearly rather than through Ur, so the encoded bytes cannot be duplicated or dropped before the FFI call.

Option 1 matches the surrounding modules. EventSorcery.Stream.commit takes ordinary arguments for the same kind of engine call.

If the linear signature is a deliberate architectural choice for the binding, record the rationale as an ADR. Otherwise the type surface suggests a guarantee that the implementation does not provide.

Also applies to: 107-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bindings/haskell/src/EventSorcery/Snapshot.hs` around lines 85 - 93, Remove
the ineffective linear SnapshotWrite wrapper: replace SnapshotWrite and
snapshotWrite with a plain storeSnapshot signature accepting StreamIdentity,
Word64, and ByteString and returning IO (Either EngineError SnapshotVersion).
Remove the SnapshotWrite GADT, Ur import, and related pattern-matching
indirection, following the ordinary argument style used by
EventSorcery.Stream.commit.

Source: Path instructions



loadSnapshot
:: Engine.Store
-> StreamIdentity
-> IO (Either EngineError (Maybe StoredSnapshot))
loadSnapshot store identity =
withOpenStore store $ \handle ->
withInputBuffer (encodeIdentity identity) $ \request -> do
response <- callWithOutput (esSnapshotLoad handle request)
pure (response >>= decodeResponse decodeStoredSnapshot)


storeSnapshot
:: Engine.Store
-> SnapshotWrite
%1 -> IO (Either EngineError SnapshotVersion)
storeSnapshot store (SnapshotWrite (Ur (identity, sequence, payload))) =
withOpenStore store $ \handle ->
withInputBuffer (encodeWrite identity sequence payload) $ \request ->
alloca $ \outVersion -> do
poke outVersion 0
stored <- callWithoutOutput (esSnapshotStore handle request outVersion)
case stored of
Left failure -> pure (Left failure)
Right () -> Right . SnapshotVersion <$> peek outVersion


discardSnapshot
:: Engine.Store
-> StreamIdentity
-> IO (Either EngineError ())
discardSnapshot store identity =
withOpenStore store $ \handle ->
withInputBuffer
(encodeIdentity identity)
(callWithoutOutput . esSnapshotDiscard handle)


encodeIdentity :: StreamIdentity -> ByteString
encodeIdentity identity =
let StreamIdentity (AggregateType aggregateType) (AggregateId aggregateId) =
identity
in toStrictByteString $
encodeListLen 3
<> encodeWord 1
<> encodeString aggregateType
<> encodeString aggregateId


encodeWrite :: StreamIdentity -> Word64 -> ByteString -> ByteString
encodeWrite identity sequence payload =
let StreamIdentity (AggregateType aggregateType) (AggregateId aggregateId) =
identity
in toStrictByteString $
encodeListLen 5
<> encodeWord 1
<> encodeString aggregateType
<> encodeString aggregateId
<> encodeWord64 sequence
<> encodeBytes payload


decodeStoredSnapshot :: ByteString -> Either String (Maybe StoredSnapshot)
decodeStoredSnapshot bytes =
case deserialiseFromBytes
decodeStoredSnapshotWire
(LazyByteString.fromStrict bytes) of
Left failure -> Left (show failure)
Right (remaining, snapshot)
| LazyByteString.null remaining -> Right snapshot
| otherwise -> Left "trailing bytes after stored snapshot"


decodeStoredSnapshotWire :: Decoder s (Maybe StoredSnapshot)
decodeStoredSnapshotWire = do
expectListLength 2
expectFormatVersion
token <- peekTokenType

case token of
TypeNull -> Nothing <$ decodeNull
_ -> do
expectListLength 3
sequence <- decodeWord64
version <- SnapshotVersion <$> decodeWord64
Just . StoredSnapshot sequence version <$> decodeBytes


expectFormatVersion :: Decoder s ()
expectFormatVersion = do
version <- decodeWord

if version == 1
then pure ()
else fail "unsupported snapshot format version"


expectListLength :: Int -> Decoder s ()
expectListLength expected = do
actual <- decodeListLen

if actual == expected
then pure ()
else fail "unexpected CBOR list length"
Comment on lines +157 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for existing snapshot codec coverage in the Haskell test suites.
fd -t f -e hs . bindings/haskell/test --exec rg -n 'Snapshot|snapshotWrite|decodeStoredSnapshot|StoredSnapshot' {}
# Show which modules WireSpec currently covers.
fd -t f 'WireSpec.hs' --exec rg -n '^import|testCase' {}

Repository: dataclique/event-sorcery

Length of output: 3334


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- documentation files ---'
fd -t f 'SPEC.md' 'domain.md' . | sort
for f in $(fd -t f 'SPEC.md' 'domain.md' . | sort); do
  printf '\n--- %s ---\n' "$f"
  sed -n '1,220p' "$f"
done
printf '%s\n' '--- Snapshot module ---'
wc -l bindings/haskell/src/EventSorcery/Snapshot.hs
sed -n '1,220p' bindings/haskell/src/EventSorcery/Snapshot.hs
printf '%s\n' '--- WireSpec snapshot-related context ---'
WIRE=$(fd -t f 'WireSpec.hs' . | head -n 1)
printf 'file=%s\n' "$WIRE"
sed -n '1,240p' "$WIRE"
printf '%s\n' '--- all codec test references ---'
rg -n -C 3 'encodeIdentity|encodeWrite|decodeStoredSnapshot|stored snapshot|snapshot' bindings/haskell/test bindings/haskell/src

Repository: dataclique/event-sorcery

Length of output: 39821


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- domain documentation ---'
sed -n '1,220p' docs/domain.md
printf '%s\n' '--- snapshot specification references ---'
rg -n -C 4 'snapshot|Serialization tests|literal|wire-level|CBOR' SPEC.md docs bindings/haskell
printf '%s\n' '--- Haskell test configuration ---'
fd -t f . bindings/haskell | rg '(cabal|package|Makefile|flake|nix)$' | sort
for f in $(fd -t f . bindings/haskell | rg '(cabal|package|Makefile|flake|nix)$' | sort); do
  printf '\n--- %s ---\n' "$f"
  rg -n -C 3 'WireSpec|StoreSpec|test-suite|exposed-modules|other-modules' "$f" || true
done
printf '%s\n' '--- Rust snapshot wire tests ---'
rg -n -C 8 'StoredSnapshotWire|snapshot.*(None|Some)|snapshot_load|snapshot_store|snapshot wire' crates bindings

Repository: dataclique/event-sorcery

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Haskell package and test-suite declarations ---'
for f in $(fd -t f . bindings/haskell | rg '\.(cabal|yaml|yml|mk|nix)$|Makefile$' | sort); do
  rg -n -C 5 'WireSpec|StoreSpec|test-suite|hs-source-dirs|other-modules|EventSorcery.Snapshot' "$f" || true
done
printf '%s\n' '--- Rust snapshot test matches only ---'
rg -n 'StoredSnapshotWire|snapshot_load|snapshot_store|snapshot.*None|snapshot.*Some' crates bindings --glob '*.rs' --glob '*.hs' | head -n 120
printf '%s\n' '--- Rust snapshot test region ---'
MATCH=$(rg -l 'StoredSnapshotWire' crates bindings --glob '*.rs' | head -n 1 || true)
if [ -n "$MATCH" ]; then
  printf 'file=%s\n' "$MATCH"
  LINE=$(rg -n 'StoredSnapshotWire' "$MATCH" | head -n 1 | cut -d: -f1)
  START=$((LINE-30)); [ "$START" -lt 1 ] && START=1
  END=$((LINE+100))
  sed -n "${START},${END}p" "$MATCH"
fi
printf '%s\n' '--- Snapshot exports and test imports ---'
rg -n -C 2 'import EventSorcery.Snapshot|module EventSorcery.Snapshot|decodeStoredSnapshot|encodeIdentity|encodeWrite' bindings/haskell --glob '*.hs'

Repository: dataclique/event-sorcery

Length of output: 15650


Add independent-literal tests for the snapshot wire codec.

The Haskell tests cover snapshots only through the store. They do not cover encodeIdentity, encodeWrite, or the decoder rejection paths for trailing bytes, unsupported format versions, and incorrect list lengths. Add a dedicated codec test suite or test the helpers through an explicit test-facing boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bindings/haskell/src/EventSorcery/Snapshot.hs` around lines 157 - 198, Add
dedicated Haskell tests for the snapshot wire codec, exercising encodeIdentity
and encodeWrite through a test-facing boundary or codec suite. Include
independent literal inputs that verify decodeStoredSnapshot rejects trailing
bytes, unsupported format versions, and incorrect CBOR list lengths, while
preserving valid snapshot and null-snapshot decoding behavior.

Source: Coding guidelines

Loading