Skip to content

feat: load Haskell entities from snapshots - #36

Merged
0xgleb merged 1 commit into
masterfrom
feat/0.4-haskell-snapshots
Aug 12, 2026
Merged

0xgleb merged 1 commit into
masterfrom
feat/0.4-haskell-snapshots

Conversation

@0xgleb

@0xgleb 0xgleb commented Jul 15, 2026 •

Copy link
Copy Markdown
Member
  • expose the existing snapshot store through the erased engine and C ABI
  • add linear Haskell snapshot writes and resume typed entity folds from snapshots
  • keep GitButler's guard while running generated hooks through prek

Verified with Rust engine and FFI tests, the Nix Haskell build, HLint, rustfmt, and Clippy.

Closes #72.


This is part 18 of 32 in a stack made with GitButler:

Summary by CodeRabbit

  • New Features
    • Added snapshot support for faster event-sourced state loading and recovery.
    • Snapshots can be created, loaded, updated, and discarded, with validation and version tracking.
    • Added Haskell bindings for snapshot management.
  • Bug Fixes
    • Added clear errors for invalid, corrupted, stale, or incompatible snapshots.
    • Improved recovery by resuming event processing after a valid snapshot.
  • Chores
    • Updated the integration interface to support snapshot operations.
    • Improved development hook handling and Rust formatting integration.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

The pull request adds snapshot persistence to the Rust engine, exposes snapshot operations through the version-4 C ABI and Haskell bindings, and updates Haskell entity loading to resume from snapshots. It also adds SQLx metadata and adjusts development hooks.

Snapshot support

Layer / File(s) Summary
Engine snapshot persistence
crates/event-sorcery/src/engine.rs, crates/event-sorcery/src/lib.rs, .sqlx/*
The engine adds transactional snapshot storage, loading, version checks, stale-write rejection, discard operations, and integration tests.
C ABI snapshot operations
crates/event-sorcery-ffi/src/lib.rs
The FFI adds version-4 snapshot wire data and load, store, and discard operations. It validates opaque payloads and tests round trips.
Haskell snapshot API and resume
bindings/haskell/src/EventSorcery/Snapshot.hs, bindings/haskell/src/EventSorcery/Store.hs, bindings/haskell/src/EventSorcery/Engine/Internal.hs, bindings/haskell/src/EventSorcery/Engine/Internal/FFI.hs, bindings/haskell/event-sorcery.cabal, bindings/haskell/test/StoreSpec.hs
The Haskell binding exposes snapshot operations and types. Store loads snapshots, resumes event replay, reports decode failures, and tests corruption, discard, and recovery.

Development hook configuration

Layer / File(s) Summary
Pre-commit and Rust formatting hooks
flake.nix, git-hooks.nix
The shell hook preserves the user hook when prek and GitButler markers exist. The Rust formatting hook runs cargo fmt --all through a custom wrapper.

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The flake.nix and git-hooks.nix changes concern hook management and are unrelated to issue #72. Move the hook-management changes to a separate pull request or link them to a requirement that covers this scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: loading Haskell entities from snapshots.
Linked Issues check ✅ Passed The changes add Haskell snapshot loading and safely resume event replay as required by issue #72.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/0.4-haskell-snapshots

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@0xgleb
0xgleb force-pushed the feat/0.4-haskell-snapshots branch from 8ac8b94 to 5ab9e26 Compare July 16, 2026 08:11
@0xgleb
0xgleb marked this pull request as ready for review July 16, 2026 08:11
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-store branch from 3497c35 to 836f740 Compare July 16, 2026 08:19
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-snapshots branch from 5ab9e26 to 1159168 Compare July 16, 2026 08:19
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-store branch from 836f740 to 397b89c Compare July 16, 2026 08:31
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-snapshots branch from 1159168 to 3be4428 Compare July 16, 2026 08:31
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-store branch from 397b89c to b184079 Compare July 16, 2026 08:32
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-snapshots branch from 3be4428 to 3de31a7 Compare July 16, 2026 08:32
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-store branch from b184079 to b1428a2 Compare July 16, 2026 09:43
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-snapshots branch from 3de31a7 to 06c14c3 Compare July 16, 2026 09:43
@0xgleb 0xgleb added this to the 0.4.0 milestone Jul 16, 2026
@0xgleb 0xgleb moved this from Todo to In Progress in event-sorcery Jul 16, 2026
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-store branch from b1428a2 to bd1e561 Compare July 16, 2026 13:57
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-snapshots branch from 06c14c3 to a3da8a6 Compare July 16, 2026 13:57
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-store branch from bd1e561 to 20bd850 Compare August 12, 2026 02:41
Base automatically changed from feat/0.4-haskell-store to feat/0.4-haskell-domain August 12, 2026 02:42
Base automatically changed from feat/0.4-haskell-domain to master August 12, 2026 02:50
@0xgleb
0xgleb force-pushed the feat/0.4-haskell-snapshots branch from a3da8a6 to 0f21746 Compare August 12, 2026 02:58
@0xgleb
0xgleb enabled auto-merge August 12, 2026 02:59
@0xgleb
0xgleb added this pull request to the merge queue Aug 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🤖 Prompt for all review comments with 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.

Inline comments:
In `@bindings/haskell/src/EventSorcery/Snapshot.hs`:
- Around line 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.
- Around line 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.
- Around line 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.

In `@bindings/haskell/src/EventSorcery/Store.hs`:
- Around line 105-123: Update loadCurrent to return both the current entity and
the sequence at which the existing snapshot was read, then adjust snapshotEntity
to compare that loaded snapshot sequence with the current sequence before
calling Snapshot.storeSnapshot. Only write a snapshot when the current sequence
has advanced past the loaded snapshot sequence; preserve the existing result
handling and allow concurrent commits between loadCurrent and storeSnapshot.
- Around line 170-176: Update the snapshot persistence and resume flow centered
on loadCurrent and resumeSnapshot to include EventSourced.schemaVersion in
StoredSnapshot and the EventSorcery.Snapshot/C ABI payload. Compare the stored
schema version with Aggregate.schemaVersion (Proxy `@entity`) before decoding;
treat mismatches as stale checkpoints and replay the stream, while preserving
StoreSnapshotDecodeFailed for decode failures at a matching version. Document
the wire-format and recovery decision in an ADR under adrs/.

In `@bindings/haskell/test/StoreSpec.hs`:
- Around line 92-129: Replace the single conjunction-driven conditional around
the test expectations with separate `shouldBe` assertions for each invariant,
including `initially`, `emptySnapshot`, `openedAccount`, `deposited`,
`snapshotted`, `afterSnapshotDeposit`, `reloaded`, `invalidEvent`,
`afterInvalidEvent`, `dispatched`, `jobs`, `rejected`, `afterRejection`,
`corrupted`, `corruptedLoad`, `discarded`, and `recovered`, preserving each
existing expected value.
- Around line 215-218: Update the Account snapshot codec in encodeSnapshot and
decodeSnapshot to use an encoding width that represents the full balance domain
without truncation, keeping malformed payloads such as the existing corrupted
snapshot rejected by decodeSnapshot. Ensure round-tripping balances above 255
preserves the original Account value.

In `@crates/event-sorcery-ffi/src/lib.rs`:
- Around line 545-547: The snapshot path currently serializes payload bytes as a
JSON number array; update the snapshot aggregate construction near sequence
conversion to reuse the existing opaque payload envelope via
encode_opaque_payload. Change snapshot_payload_bytes to decode that
representation with the matching decoder, preserving payload limits and the
established provenance protection used by the event path.

In `@crates/event-sorcery/src/engine.rs`:
- Around line 1063-1074: Update store_snapshot_in_transaction to use the
existing committed_stream_version oracle instead of loading events through
load_events_on and reading events.last().sequence. Preserve the empty-stream
behavior when no committed version exists, while allowing compacted streams to
validate snapshot.last_sequence against the version reported by
committed_stream_version.
- Around line 1982-2055: Extend the snapshot tests in
crates/event-sorcery/src/engine.rs:1982-2055 around
snapshot_operations_use_the_existing_snapshot_table to exercise
SnapshotBeyondCurrentVersion and EmptySnapshotUpdate through store_snapshot,
asserting each rejected write leaves the persisted snapshot unchanged. Also
extend the FFI snapshot tests in crates/event-sorcery-ffi/src/lib.rs:1455-1467
to write a JSON-object snapshot through the leased engine and assert
es_snapshot_load returns ES_ERR_STORAGE for
SnapshotPayloadFault::NotOpaqueBytes, plus add coverage for ByteOutOfRange.
- Around line 639-668: Update store_snapshot to acquire a transaction through
the pool API using the existing immediate-transaction pattern, rather than
acquiring a PoolConnection and issuing raw BEGIN IMMEDIATE. Pass the transaction
to store_snapshot_in_transaction, commit it on success, and rely on
sqlx::Transaction’s drop rollback behavior on failure or cancellation; remove
the manual COMMIT/ROLLBACK handling and any now-unused query metadata.

In `@flake.nix`:
- Around line 165-168: Update the hook migration logic around the
generated-by-prek and GITBUTLER_MANAGED_HOOK_V1 checks to resolve the active
hooks directory via git rev-parse --git-path hooks, then use hooks_dir for every
marker lookup and mv operation instead of .git/hooks. Add coverage for standard
repositories, linked worktrees, and repositories with configured core.hooksPath.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5a204bb7-a2c3-47be-b08a-ca2105f58dce

📥 Commits

Reviewing files that changed from the base of the PR and between 00a3474 and 0f21746.

📒 Files selected for processing (15)
  • .sqlx/query-587fa628a9fe4642ac0e22825a97e81d388af674f7251a9fb699f96901dcb710.json
  • .sqlx/query-79663c1d3b43ceaf9ee728e501c64b57ae2e4d6cb36ff5c65eddcda1de27342f.json
  • .sqlx/query-930a7770399087898ae6ac96ce5375048117486e06b21da4523d2c3c75113c32.json
  • .sqlx/query-d8d7bee8e77c496d9510ccb0a632d70da8f647b4ce32ca7526a5e18d709d1d65.json
  • bindings/haskell/event-sorcery.cabal
  • bindings/haskell/src/EventSorcery/Engine/Internal.hs
  • bindings/haskell/src/EventSorcery/Engine/Internal/FFI.hs
  • bindings/haskell/src/EventSorcery/Snapshot.hs
  • bindings/haskell/src/EventSorcery/Store.hs
  • bindings/haskell/test/StoreSpec.hs
  • crates/event-sorcery-ffi/src/lib.rs
  • crates/event-sorcery/src/engine.rs
  • crates/event-sorcery/src/lib.rs
  • flake.nix
  • git-hooks.nix
📜 Review details
⏰ Context from checks skipped due to timeout. (7)
  • GitHub Check: fmt
  • GitHub Check: test
  • GitHub Check: examples
  • GitHub Check: haskell
  • GitHub Check: check
  • GitHub Check: clippy
  • GitHub Check: hooks
🧰 Additional context used
📓 Path-based instructions (3)
**/*

📄 CodeRabbit inference engine (AGENTS.md)

**/*: Before work, read SPEC.md and docs/domain.md; read relevant supplemental documentation before implementation.
New features must be documented in SPEC.md before implementation and must follow the hierarchy SPEC.md -> issue -> plan -> tests -> implementation.
Fix all known problems immediately, complete all tasks, and do not allow warnings or errors to pass through.
Keep a granular task list and clear completed tasks from the active list.
All new or modified logic must have corresponding test coverage.
Understand relevant documentation and source code before implementation, keep diffs small, and review the approach critically.
When changing direction or making an important undocumented architectural decision, obtain confirmation; record significant decisions as ADRs under adrs/.
Before handover, review the diff, revert unjustified changes, and check for scope creep.
Each aggregate in a consuming application must use exactly one SqliteCqrs instance constructed at startup; per-request construction is forbidden.
Never read secret or credential files such as .env*, credentials.json, *.key, *.pem, *.p12, *.pfx, or sensitive database files without explicit permission.
Never bypass, disable, suppress, or obscure quality-control mechanisms without explicit permission; fix lint and test issues at their root.
Use cargo check, cargo nextest, and cargo clippy for verification; never use cargo build unless build artifacts are required.

Files:

  • bindings/haskell/event-sorcery.cabal
  • crates/event-sorcery/src/lib.rs
  • bindings/haskell/src/EventSorcery/Engine/Internal/FFI.hs
  • bindings/haskell/src/EventSorcery/Engine/Internal.hs
  • bindings/haskell/src/EventSorcery/Snapshot.hs
  • bindings/haskell/src/EventSorcery/Store.hs
  • bindings/haskell/test/StoreSpec.hs
  • git-hooks.nix
  • flake.nix
  • crates/event-sorcery/src/engine.rs
  • crates/event-sorcery-ffi/src/lib.rs
crates/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

crates/**/*.rs: Organize code by business feature rather than technical layer; avoid catch-all modules such as types.rs, error.rs, models.rs, utils.rs, helpers.rs, and services.rs.
Never write directly to the events table; emit events through CqrsFramework::execute() or execute_with_metadata().
Use cqrs-es Services for side effects in handle() and follow the {Action}er -> {Domain}Service -> {Domain}Manager naming pattern.
Place command-execution logging in aggregate handle() methods rather than callers.
Model invalid states with enums, ADTs, newtypes, and typestate rather than relying on runtime validation.
Use domain newtypes at APIs and convert to SDK primitives inside the callee, except at cross-crate boundaries where conversion at the call site is necessary.
Keep visibility as restrictive as possible: private over pub(crate) over pub.
Use a three-group import order: external crates, workspace crates, then crate-internal imports; do not use function-level imports except enum variants.
Do not use unwrap() or expect() in production Rust code; they are permitted in #[cfg(test)] code.
Never create error variants containing opaque String values; prefer #[from], ?, #[source], and preserve error chains.
Log a warning or error before silent early returns such as let-else failures.
Never silently mask numeric failures with caps, fallback defaults, precision truncation, unwrap_or(), or unwrap_or_default(); use explicit checked conversions and errors.
Prefer functional patterns, pattern matching, combinators, type-driven design, and iterators over imperative loops unless complexity increases.
Use ASCII in identifiers, comments, log messages, and configuration keys; Unicode is preferred only in user-facing rendered output.
Do not use single-letter variables, arguments, closure parameters, or generic type parameters except an unambiguous lone type parameter or short unambiguous closure.
Every module must have a //! docstring and should order public API, private implementati...

Files:

  • crates/event-sorcery/src/lib.rs
  • crates/event-sorcery/src/engine.rs
  • crates/event-sorcery-ffi/src/lib.rs
*

⚙️ CodeRabbit configuration file

Focus on providing constructive criticism. Whenever you see a suboptimal approach, suggest more idiomatic or robust alternative(s). Flag potential footguns. Suggest FP alternatives to mutable/imperative code. Point out architectural flaws like leaky abstractions, tight coupling, wrong level of abstraction, poor type modeling, over-abstraction, unclear domain boundaries. Code should generally be organized based on business concerns rather than technical aspects - suggest improvements if you find violations. Point out gaps in test coverage but suggest tests that are not too coupled to the implementation and actually test domain invariants and business logic

Files:

  • git-hooks.nix
  • flake.nix
🧠 Learnings (6)
📚 Learning: 2026-07-16T15:10:47.551Z
Learnt from: 0xgleb
Repo: dataclique/event-sorcery PR: 24
File: crates/event-sorcery/src/lib.rs:88-88
Timestamp: 2026-07-16T15:10:47.551Z
Learning: In `crates/event-sorcery/src/lib.rs`, the crate-root `engine` module should remain private (`mod engine;`). Rust permits descendant modules, including `crates/event-sorcery/src/job_sqlite.rs` and `crates/event-sorcery/src/sqlite_event_repository.rs`, to access it through `crate::engine`; it does not need `pub(crate)` visibility for that internal access.

Applied to files:

  • bindings/haskell/event-sorcery.cabal
  • crates/event-sorcery/src/lib.rs
  • crates/event-sorcery/src/engine.rs
  • crates/event-sorcery-ffi/src/lib.rs
📚 Learning: 2026-07-16T21:10:49.512Z
Learnt from: 0xgleb
Repo: dataclique/event-sorcery PR: 0
File: :0-0
Timestamp: 2026-07-16T21:10:49.512Z
Learning: In `crates/event-sorcery/src/job_backend.rs`, `JobClaimHandle` is deliberately non-serializable. It contains private fencing identity, so foreign-language bindings must retain it in trusted process memory and expose only a binding-owned opaque token; accepting caller-provided serialized handles could permit forged claim state.

Applied to files:

  • crates/event-sorcery/src/lib.rs
  • crates/event-sorcery/src/engine.rs
  • crates/event-sorcery-ffi/src/lib.rs
📚 Learning: 2026-07-16T09:05:57.710Z
Learnt from: CR
Repo: dataclique/event-sorcery PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-16T09:05:57.710Z
Learning: Applies to crates/**/*.rs : Use in-memory SQLite pools via sqlite_es::testing::create_test_pool() for database test isolation.

Applied to files:

  • crates/event-sorcery/src/engine.rs
  • crates/event-sorcery-ffi/src/lib.rs
📚 Learning: 2026-07-16T17:19:57.719Z
Learnt from: 0xgleb
Repo: dataclique/event-sorcery PR: 26
File: crates/event-sorcery-ffi/build.rs:0-0
Timestamp: 2026-07-16T17:19:57.719Z
Learning: For the `event-sorcery-ffi` C ABI, `event_sorcery.h` is a generated build artifact defined by `SPEC.md` and must not be hand-maintained or written into the checked-in source tree during builds. `crates/event-sorcery-ffi/build.rs` should generate it under `OUT_DIR`; publishing it for consumers is deferred to a separately defined release/package destination.

Applied to files:

  • crates/event-sorcery-ffi/src/lib.rs
📚 Learning: 2026-07-16T09:05:57.710Z
Learnt from: CR
Repo: dataclique/event-sorcery PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-16T09:05:57.710Z
Learning: Applies to crates/**/*.rs : Never write directly to the events table; emit events through CqrsFramework::execute() or execute_with_metadata().

Applied to files:

  • crates/event-sorcery-ffi/src/lib.rs
📚 Learning: 2026-07-16T09:05:57.710Z
Learnt from: CR
Repo: dataclique/event-sorcery PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-16T09:05:57.710Z
Learning: Applies to crates/**/*.rs : Serialization tests must compare output with independent literals such as json!(), not re-serialized domain values.

Applied to files:

  • crates/event-sorcery-ffi/src/lib.rs
🔇 Additional comments (16)
git-hooks.nix (1)

8-16: 🩺 Stability & Availability

The pinned git-hooks.nix rustfmt hook already sets pass_filenames = false; no change is required.

			> Likely an incorrect or invalid review comment.
crates/event-sorcery/src/engine.rs (1)

158-172: LGTM!

Also applies to: 197-200, 396-397, 1236-1238

.sqlx/query-79663c1d3b43ceaf9ee728e501c64b57ae2e4d6cb36ff5c65eddcda1de27342f.json (1)

1-12: LGTM!

.sqlx/query-d8d7bee8e77c496d9510ccb0a632d70da8f647b4ce32ca7526a5e18d709d1d65.json (1)

1-12: LGTM!

crates/event-sorcery/src/lib.rs (1)

130-130: LGTM!

crates/event-sorcery-ffi/src/lib.rs (1)

19-19: LGTM!

Also applies to: 30-34, 78-80, 471-512, 573-594, 1402-1403, 1732-1733, 2375-2376, 4378-4460

bindings/haskell/test/StoreSpec.hs (1)

31-39: LGTM!

Also applies to: 68-90

.sqlx/query-587fa628a9fe4642ac0e22825a97e81d388af674f7251a9fb699f96901dcb710.json (1)

1-12: LGTM!

.sqlx/query-930a7770399087898ae6ac96ce5375048117486e06b21da4523d2c3c75113c32.json (1)

1-12: LGTM!

bindings/haskell/event-sorcery.cabal (1)

58-58: LGTM!

bindings/haskell/src/EventSorcery/Engine/Internal.hs (1)

143-147: LGTM!

bindings/haskell/src/EventSorcery/Snapshot.hs (2)

96-104: LGTM!

Also applies to: 122-130


133-154: LGTM!

bindings/haskell/src/EventSorcery/Store.hs (2)

7-7: LGTM!

Also applies to: 30-43, 67-67, 313-317


205-205: 🎯 Functional Correctness

No change needed: loadStream ... (Just sequence) uses sequence > ?3, so it excludes the snapshot event and matches resume's nextPosition.

			> Likely an incorrect or invalid review comment.
bindings/haskell/src/EventSorcery/Engine/Internal/FFI.hs (1)

20-22: 🗄️ Data Integrity & Integration

No header change is required. event_sorcery.h is generated from the exported Rust ABI and packaged under $out/include; the three snapshot functions are declared for the Haskell capi imports.

			> Likely an incorrect or invalid review comment.

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

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

Comment on lines +85 to +93
data SnapshotWrite where
SnapshotWrite
:: Ur (StreamIdentity, Word64, ByteString)
%1 -> SnapshotWrite


snapshotWrite :: StreamIdentity -> Word64 -> ByteString -> SnapshotWrite
snapshotWrite identity sequence payload =
SnapshotWrite (Ur (identity, sequence, payload))

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

Comment on lines +157 to +198
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"

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

Comment on lines +105 to +123
snapshotEntity store@(Store engine _) key = do
loaded <- loadCurrent store key

case loaded of
Left failure -> pure (Left failure)
Right (Nothing, _) -> pure (Right Nothing)
Right (Just entity, sequence) -> do
stored <-
Snapshot.storeSnapshot
engine
( Snapshot.snapshotWrite
(streamKeyIdentity key)
sequence
(Aggregate.encodeSnapshot entity)
)

pure case stored of
Left failure -> Left (StoreEngineFailed failure)
Right _ -> Right (Just entity)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

snapshotEntity writes a redundant snapshot when the stream has not advanced.

loadCurrent returns the sequence reached after resume. If a snapshot already exists at sequence N and no event has been committed since, resumeSnapshot loads an empty event list and latestSequenceAfter N [] = N. snapshotEntity then stores a snapshot at the same sequence N.

The engine accepts this write. store_snapshot_in_transaction rejects only when stored.current_sequence > snapshot.last_sequence, and N > N is false. It writes a new row and increments snapshot_version. Repeated snapshotEntity calls with no intervening events increment the version without bound.

Each such call also costs a full transactional write plus, inside the engine, a load of the entire event stream to determine the current sequence. A periodic snapshotting job over idle streams pays that cost for no benefit.

Skip the write when the sequence has not advanced past the loaded snapshot. That requires loadCurrent to report the sequence the snapshot was read at, so snapshotEntity can compare.

Note also that loadCurrent and storeSnapshot are two separate engine calls. A concurrent commit between them causes a snapshot at an older sequence to be written. That is safe, because resume replays the events after the recorded sequence. No fix is needed for that case.

🤖 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/Store.hs` around lines 105 - 123, Update
loadCurrent to return both the current entity and the sequence at which the
existing snapshot was read, then adjust snapshotEntity to compare that loaded
snapshot sequence with the current sequence before calling
Snapshot.storeSnapshot. Only write a snapshot when the current sequence has
advanced past the loaded snapshot sequence; preserve the existing result
handling and allow concurrent commits between loadCurrent and storeSnapshot.

Comment on lines 170 to +176
loadCurrent (Store engine _) key = do
loadedSnapshot <- Snapshot.loadSnapshot engine (streamKeyIdentity key)

case loadedSnapshot of
Left failure -> pure (Left (StoreEngineFailed failure))
Right Nothing -> replayFullStream engine key
Right (Just snapshot) -> resumeSnapshot engine key snapshot

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

A snapshot decode failure permanently blocks every read of the stream, and there is no schema-version guard.

loadCurrent routes any stored snapshot to resumeSnapshot. If Aggregate.decodeSnapshot rejects the payload, the call returns StoreSnapshotDecodeFailed. loadEntity, executeCommand, and snapshotEntity all go through loadCurrent, so all three fail for that stream. The event stream is still intact, and a full replay would succeed.

The realistic trigger is not corruption. It is schema evolution. When an entity changes its encodeSnapshot/decodeSnapshot representation, every previously stored snapshot becomes undecodable at once. Recovery then requires an out-of-band discardSnapshot call for every affected stream.

EventSourced already declares schemaVersion, but StoredSnapshot carries only the sequence, the monotonic snapshot version, and the payload. Nothing records which aggregate schema produced the payload. A stale-schema snapshot is therefore indistinguishable from a corrupt one.

Record the aggregate schemaVersion with the snapshot payload. resumeSnapshot can then compare it against Aggregate.schemaVersion (Proxy @entity) before decoding, and treat a mismatch as a stale checkpoint rather than a failure. A decode failure at a matching schema version stays a hard error, which keeps genuine corruption loud.

This crosses the wire format, so it also affects EventSorcery.Snapshot and the C ABI payload. Confirm the intended behavior before merge, and record the decision as an ADR under adrs/.

Also applies to: 194-203

🤖 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/Store.hs` around lines 170 - 176, Update
the snapshot persistence and resume flow centered on loadCurrent and
resumeSnapshot to include EventSourced.schemaVersion in StoredSnapshot and the
EventSorcery.Snapshot/C ABI payload. Compare the stored schema version with
Aggregate.schemaVersion (Proxy `@entity`) before decoding; treat mismatches as
stale checkpoints and replay the stream, while preserving
StoreSnapshotDecodeFailed for decode failures at a matching version. Document
the wire-format and recovery decision in an ADR under adrs/.

Source: Path instructions

Comment on lines +545 to +547
require_payload_limit(payload.len())?;
let sequence = usize::try_from(sequence).map_err(AbiError::InputInteger)?;
let aggregate = serde_json::Value::Array(payload.into_iter().map(Into::into).collect());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Reuse the opaque payload envelope instead of a JSON number array.

Line 547 stores an N-byte snapshot as a serde_json::Value::Array of N JSON numbers. Three consequences follow:

  • Storage cost grows roughly four times. A byte such as 255 occupies four characters in the stored JSON text.
  • Every load rebuilds the Vec<u8> element by element through snapshot_payload_bytes, so decoding is proportional to the element count rather than a byte copy.
  • require_payload_limit(payload.len()) at Line 545 bounds the request bytes, not the stored bytes. The persisted row can exceed that bound by a large factor.

The event path already solves this problem. encode_opaque_payload and decode_opaque_event in crates/event-sorcery/src/engine.rs wrap binding-owned bytes in the $event-sorcery-engine-payload envelope, and native_metadata_cannot_claim_the_opaque_payload_envelope protects the provenance. The snapshot path introduces a second, incompatible representation for the same concern.

Apply the existing envelope to snapshot payloads, and derive snapshot_payload_bytes from the matching decoder. That keeps one opaque-bytes contract across events and snapshots.

🤖 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 `@crates/event-sorcery-ffi/src/lib.rs` around lines 545 - 547, The snapshot
path currently serializes payload bytes as a JSON number array; update the
snapshot aggregate construction near sequence conversion to reuse the existing
opaque payload envelope via encode_opaque_payload. Change snapshot_payload_bytes
to decode that representation with the matching decoder, preserving payload
limits and the established provenance protection used by the event path.

Comment on lines +639 to +668
let mut connection = self.pool.acquire().await?;
load_snapshot_on(&mut connection, stream).await
}

pub async fn store_snapshot(
&self,
stream: &StreamIdentity,
snapshot: SnapshotWrite,
) -> Result<usize, EngineError> {
let mut connection = self.pool.acquire().await?;
sqlx::query!("BEGIN IMMEDIATE")
.execute(&mut *connection)
.await?;
let result = store_snapshot_in_transaction(&mut connection, stream, snapshot).await;
let commit = result.is_ok();
let close = if commit {
sqlx::query!("COMMIT").execute(&mut *connection).await
} else {
sqlx::query!("ROLLBACK").execute(&mut *connection).await
};

match close {
Err(error) if commit => Err(EngineError::Sql(error)),
Err(error) => {
tracing::warn!(target: "cqrs", ?error, "snapshot rollback failed");
result
}
Ok(_) => result,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Use the pool transaction API instead of raw BEGIN IMMEDIATE on a pooled connection.

store_snapshot runs BEGIN IMMEDIATE directly on a PoolConnection. If the future is cancelled between the BEGIN IMMEDIATE and the COMMIT/ROLLBACK, the connection returns to the pool with an open write transaction. This crate already treats that as a known hazard: commit_serialized uses self.pool.begin_with("BEGIN IMMEDIATE") at Line 829, and claim_job uses the immediate_transaction helper. The test cancelled_immediate_transaction_does_not_poison_the_pooled_connection exists for exactly this failure mode.

sqlx::Transaction rolls back on drop, so the pool-level API removes the cancellation hole and the manual commit/rollback branch.

♻️ Proposed fix using the existing transaction API
     pub async fn store_snapshot(
         &self,
         stream: &StreamIdentity,
         snapshot: SnapshotWrite,
     ) -> Result<usize, EngineError> {
-        let mut connection = self.pool.acquire().await?;
-        sqlx::query!("BEGIN IMMEDIATE")
-            .execute(&mut *connection)
-            .await?;
-        let result = store_snapshot_in_transaction(&mut connection, stream, snapshot).await;
-        let commit = result.is_ok();
-        let close = if commit {
-            sqlx::query!("COMMIT").execute(&mut *connection).await
-        } else {
-            sqlx::query!("ROLLBACK").execute(&mut *connection).await
-        };
-
-        match close {
-            Err(error) if commit => Err(EngineError::Sql(error)),
-            Err(error) => {
-                tracing::warn!(target: "cqrs", ?error, "snapshot rollback failed");
-                result
-            }
-            Ok(_) => result,
-        }
+        let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?;
+        let version = store_snapshot_in_transaction(&mut transaction, stream, snapshot).await?;
+        transaction.commit().await?;
+
+        Ok(version)
     }

This also removes the now-unused .sqlx/query-79663c1d…json (COMMIT) metadata entry.

📝 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
let mut connection = self.pool.acquire().await?;
load_snapshot_on(&mut connection, stream).await
}
pub async fn store_snapshot(
&self,
stream: &StreamIdentity,
snapshot: SnapshotWrite,
) -> Result<usize, EngineError> {
let mut connection = self.pool.acquire().await?;
sqlx::query!("BEGIN IMMEDIATE")
.execute(&mut *connection)
.await?;
let result = store_snapshot_in_transaction(&mut connection, stream, snapshot).await;
let commit = result.is_ok();
let close = if commit {
sqlx::query!("COMMIT").execute(&mut *connection).await
} else {
sqlx::query!("ROLLBACK").execute(&mut *connection).await
};
match close {
Err(error) if commit => Err(EngineError::Sql(error)),
Err(error) => {
tracing::warn!(target: "cqrs", ?error, "snapshot rollback failed");
result
}
Ok(_) => result,
}
}
let mut connection = self.pool.acquire().await?;
load_snapshot_on(&mut connection, stream).await
}
pub async fn store_snapshot(
&self,
stream: &StreamIdentity,
snapshot: SnapshotWrite,
) -> Result<usize, EngineError> {
let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let version = store_snapshot_in_transaction(&mut transaction, stream, snapshot).await?;
transaction.commit().await?;
Ok(version)
}
🤖 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 `@crates/event-sorcery/src/engine.rs` around lines 639 - 668, Update
store_snapshot to acquire a transaction through the pool API using the existing
immediate-transaction pattern, rather than acquiring a PoolConnection and
issuing raw BEGIN IMMEDIATE. Pass the transaction to
store_snapshot_in_transaction, commit it on success, and rely on
sqlx::Transaction’s drop rollback behavior on failure or cancellation; remove
the manual COMMIT/ROLLBACK handling and any now-unused query metadata.

Comment on lines +1063 to +1074
let events = load_events_on(connection, stream, None).await?;
let current_sequence = events
.last()
.map(|event| event.sequence)
.ok_or(EngineError::EmptySnapshotUpdate)?;

if snapshot.last_sequence > current_sequence {
return Err(EngineError::SnapshotBeyondCurrentVersion {
proposed: snapshot.last_sequence,
current: current_sequence,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Do not load the whole event stream to read one sequence number.

store_snapshot_in_transaction calls load_events_on(connection, stream, None) and then uses only events.last().sequence. This materializes and deserializes every event in the stream on each snapshot write. Snapshots exist to avoid that cost, so the write path scales with the exact quantity the snapshot removes.

This also produces a wrong result after compaction. committed_stream_version (Line 915) already reports MAX(events.sequence, snapshots.last_sequence), and its doc states that a compacted stream reports the version its snapshot covers. load_events_on sees an empty events table after compaction, so store_snapshot returns EmptySnapshotUpdate for a stream that has a valid version.

♻️ Proposed fix using the existing version oracle
-    let events = load_events_on(connection, stream, None).await?;
-    let current_sequence = events
-        .last()
-        .map(|event| event.sequence)
-        .ok_or(EngineError::EmptySnapshotUpdate)?;
-
-    if snapshot.last_sequence > current_sequence {
+    let current_sequence = committed_stream_version(connection, stream).await?;
+    if current_sequence == 0 {
+        return Err(EngineError::EmptySnapshotUpdate);
+    }
+
+    if snapshot.last_sequence > current_sequence {
         return Err(EngineError::SnapshotBeyondCurrentVersion {
             proposed: snapshot.last_sequence,
             current: current_sequence,
         });
     }
📝 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
let events = load_events_on(connection, stream, None).await?;
let current_sequence = events
.last()
.map(|event| event.sequence)
.ok_or(EngineError::EmptySnapshotUpdate)?;
if snapshot.last_sequence > current_sequence {
return Err(EngineError::SnapshotBeyondCurrentVersion {
proposed: snapshot.last_sequence,
current: current_sequence,
});
}
let current_sequence = committed_stream_version(connection, stream).await?;
if current_sequence == 0 {
return Err(EngineError::EmptySnapshotUpdate);
}
if snapshot.last_sequence > current_sequence {
return Err(EngineError::SnapshotBeyondCurrentVersion {
proposed: snapshot.last_sequence,
current: current_sequence,
});
}
🤖 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 `@crates/event-sorcery/src/engine.rs` around lines 1063 - 1074, Update
store_snapshot_in_transaction to use the existing committed_stream_version
oracle instead of loading events through load_events_on and reading
events.last().sequence. Preserve the empty-stream behavior when no committed
version exists, while allowing compacted streams to validate
snapshot.last_sequence against the version reported by committed_stream_version.

Comment on lines +1982 to +2055

#[tokio::test]
async fn snapshot_operations_use_the_existing_snapshot_table() {
let pool = create_test_pool().await.unwrap();
let engine = Engine::new(pool);
let stream = StreamIdentity::new("engine-snapshot-test", "one");
let event = SerializedEvent {
aggregate_type: "engine-snapshot-test".to_string(),
aggregate_id: "one".to_string(),
sequence: 1,
event_type: "Created".to_string(),
event_version: "1.0".to_string(),
payload: serde_json::json!({}),
metadata: serde_json::json!({}),
};
engine
.commit(CommitRequest::new(stream.clone(), &[event]))
.await
.unwrap();

let version = engine
.store_snapshot(
&stream,
SnapshotWrite::new(serde_json::json!({ "value": 42 }), 1),
)
.await
.unwrap();

assert_eq!(version, 1);
let snapshot = engine.load_snapshot(&stream).await.unwrap().unwrap();
assert_eq!(snapshot.current_sequence, 1);
assert_eq!(snapshot.current_snapshot, 1);
assert_eq!(snapshot.aggregate, serde_json::json!({ "value": 42 }));

let second_event = SerializedEvent {
aggregate_type: "engine-snapshot-test".to_string(),
aggregate_id: "one".to_string(),
sequence: 2,
event_type: "Changed".to_string(),
event_version: "1.0".to_string(),
payload: serde_json::json!({}),
metadata: serde_json::json!({}),
};
engine
.commit(CommitRequest::new(stream.clone(), &[second_event]))
.await
.unwrap();
assert_eq!(
engine
.store_snapshot(
&stream,
SnapshotWrite::new(serde_json::json!({ "value": 84 }), 2),
)
.await
.unwrap(),
2
);
assert!(matches!(
engine
.store_snapshot(
&stream,
SnapshotWrite::new(serde_json::json!({ "value": 21 }), 1),
)
.await,
Err(EngineError::OptimisticLock)
));
let current = engine.load_snapshot(&stream).await.unwrap().unwrap();
assert_eq!(current.current_sequence, 2);
assert_eq!(current.current_snapshot, 2);
assert_eq!(current.aggregate, serde_json::json!({ "value": 84 }));

engine.discard_snapshot(&stream).await.unwrap();
assert_eq!(engine.load_snapshot(&stream).await.unwrap(), None);
}

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 | 🟠 Major | ⚡ Quick win

Snapshot tests cover only the accepted path at both layers. This PR adds new rejection paths in the engine and in the C ABI, but each new test exercises a successful store, load, and discard sequence. Every new failure variant is unreachable from the suite, so a regression in snapshot validation ships silently.

  • crates/event-sorcery/src/engine.rs#L1982-L2055: add cases that trigger EngineError::SnapshotBeyondCurrentVersion and EngineError::EmptySnapshotUpdate through store_snapshot, and assert the stored snapshot is unchanged after each rejection.
  • crates/event-sorcery-ffi/src/lib.rs#L1455-L1467: add a case that writes a JSON object snapshot through the leased engine, then asserts es_snapshot_load returns ES_ERR_STORAGE for SnapshotPayloadFault::NotOpaqueBytes, and a case covering ByteOutOfRange.

As per coding guidelines: "All new or modified logic must have corresponding test coverage."

📍 Affects 2 files
  • crates/event-sorcery/src/engine.rs#L1982-L2055 (this comment)
  • crates/event-sorcery-ffi/src/lib.rs#L1455-L1467
🤖 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 `@crates/event-sorcery/src/engine.rs` around lines 1982 - 2055, Extend the
snapshot tests in crates/event-sorcery/src/engine.rs:1982-2055 around
snapshot_operations_use_the_existing_snapshot_table to exercise
SnapshotBeyondCurrentVersion and EmptySnapshotUpdate through store_snapshot,
asserting each rejected write leaves the persisted snapshot unchanged. Also
extend the FFI snapshot tests in crates/event-sorcery-ffi/src/lib.rs:1455-1467
to write a JSON-object snapshot through the leased engine and assert
es_snapshot_load returns ES_ERR_STORAGE for
SnapshotPayloadFault::NotOpaqueBytes, plus add coverage for ByteOutOfRange.

Source: Coding guidelines

Comment thread flake.nix
Comment on lines +165 to +168
if grep -q 'generated by prek' .git/hooks/pre-commit 2>/dev/null; then
if grep -q GITBUTLER_MANAGED_HOOK_V1 .git/hooks/pre-commit.legacy 2>/dev/null; then
mv .git/hooks/pre-commit .git/hooks/pre-commit-user
mv .git/hooks/pre-commit.legacy .git/hooks/pre-commit

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf 'Active hooks directory: '
git rev-parse --git-path hooks

git config --show-origin --get core.hooksPath || true
git worktree list

for path in flake.nix SPEC.md docs/domain.md; do
  if test -f "$path"; then
    rg -n -C 4 \
      'GITBUTLER_MANAGED_HOOK_V1|generated by prek|pre-commit(\.legacy|-user)?' \
      "$path" || true
  fi
done

Repository: dataclique/event-sorcery

Length of output: 751


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- required documentation ---'
for path in SPEC.md docs/domain.md; do
  if test -f "$path"; then
    printf '\n### %s (%s lines)\n' "$path" "$(wc -l < "$path")"
    sed -n '1,220p' "$path"
  else
    printf '\n### %s: missing\n' "$path"
  fi
done

printf '\n%s\n' '--- flake.nix structure and relevant context ---'
ast-grep outline flake.nix --lang nix 2>/dev/null || true
sed -n '130,190p' flake.nix

printf '\n%s\n' '--- hook-related references ---'
rg -n -C 5 \
  'shellHook|git rev-parse|core\.hooksPath|\.git/hooks|pre-commit\.legacy|pre-commit-user|generated by prek|GITBUTLER_MANAGED_HOOK_V1' \
  --glob '!result' --glob '!node_modules' .

printf '\n%s\n' '--- repository status and tracked paths ---'
git status --short
git ls-files SPEC.md docs/domain.md flake.nix

Repository: dataclique/event-sorcery

Length of output: 23012


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- hook definition and flake inputs ---'
sed -n '1,140p' flake.nix
rg -n -C 8 \
  'hooks\s*=|prek|git-hooks|shellHook|pre-commit|GITBUTLER_MANAGED_HOOK_V1' \
  flake.nix README.md docs SPEC.md .github 2>/dev/null || true

printf '\n%s\n' '--- Git path resolution probes ---'
printf 'default hooks path: '
git rev-parse --git-path hooks
printf 'overridden hooks path: '
git -c core.hooksPath=.custom-hooks rev-parse --git-path hooks
printf 'absolute hooks path: '
git -c core.hooksPath=/tmp/event-sorcery-hooks rev-parse --git-path hooks
printf 'git directory: '
git rev-parse --git-dir
printf 'common git directory: '
git rev-parse --git-common-dir

printf '\n%s\n' '--- current path types ---'
test -e .git && stat -c '%F %n' .git || true
test -e .git/hooks && stat -c '%F %n' .git/hooks || true

Repository: dataclique/event-sorcery

Length of output: 9525


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- git-hooks.nix ---'
wc -l git-hooks.nix
sed -n '1,260p' git-hooks.nix

printf '\n%s\n' '--- repository metadata relevant to worktrees and hooks ---'
git config --show-origin --get-regexp '^(core\.hooksPath|extensions\.worktreeConfig|worktree\.)' || true
git worktree list --porcelain
git rev-parse --is-inside-work-tree
git rev-parse --is-bare-repository
git rev-parse --show-toplevel

printf '\n%s\n' '--- all hook path references ---'
rg -n -C 6 \
  'git-path hooks|core\.hooksPath|\.git/hooks|hooksPath|pre-commit\.legacy|pre-commit-user|GITBUTLER_MANAGED_HOOK_V1' \
  --glob '!flake.lock' .

Repository: dataclique/event-sorcery

Length of output: 1800


🌐 Web query:

git githooks core.hooksPath git rev-parse --git-path hooks linked worktree official documentation

💡 Result:

Git hooks are generally managed via the core.hooksPath configuration variable, which allows you to specify a custom directory for hook scripts instead of the default $GIT_DIR/hooks [1][2]. Regarding your query about worktrees and path resolution: core.hooksPath By default, Git looks for hooks in $GIT_DIR/hooks [1][2]. Setting core.hooksPath overrides this globally or per-repository [1]. The path can be absolute or relative (relative to the directory where the hooks are run) [1][3]. You can disable hooks entirely by setting this to /dev/null [1][3]. git rev-parse --git-path This command is the standard way to resolve paths within the Git repository structure, including the hooks directory [4][5]. It respects overrides, including core.hooksPath [6][5]. If you run git rev-parse --git-path hooks in a repository, it will return the configured hooks directory [6]. Linked Worktrees and Hooks Git worktrees are managed by a main repository ($GIT_COMMON_DIR) and private directories for each worktree ($GIT_DIR) [4][7][8]. By default, Git does not provide separate, per-worktree hooks directories; all worktrees typically default to the same hooks location (the main repository's hooks directory) [9][10]. If you need worktree-specific hooks, you can configure them by enabling worktree-specific configuration and setting core.hooksPath specifically for that worktree [10]: 1. Enable worktree-specific configuration (run this in the main repository): git config extensions.worktreeconfig true 2. Set a custom, worktree-specific hooks path (run this inside the linked worktree): hooks_dir="$(git rev-parse --git-dir)/hooks" git config --worktree core.hooksPath "$hooks_dir" mkdir -p "$hooks_dir" Using git rev-parse --git-path within a worktree correctly resolves paths relative to the appropriate directory ($GIT_DIR vs $GIT_COMMON_DIR) depending on the resource being requested [4][7]. For hooks specifically, git rev-parse --git-path hooks will return the location determined by your current core.hooksPath configuration [6][5].

Citations:


Resolve the active Git hooks directory before moving hooks.

When core.hooksPath is configured or the repository uses a linked worktree, .git/hooks may be inactive. Resolve hooks_dir with git rev-parse --git-path hooks and use it for every marker check and mv operation. Add coverage for normal repositories, linked worktrees, and configured core.hooksPath.

🤖 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 `@flake.nix` around lines 165 - 168, Update the hook migration logic around the
generated-by-prek and GITBUTLER_MANAGED_HOOK_V1 checks to resolve the active
hooks directory via git rev-parse --git-path hooks, then use hooks_dir for every
marker lookup and mv operation instead of .git/hooks. Add coverage for standard
repositories, linked worktrees, and repositories with configured core.hooksPath.

Source: Coding guidelines

Merged via the queue into master with commit d0bd8f5 Aug 12, 2026
8 checks passed
@0xgleb
0xgleb deleted the feat/0.4-haskell-snapshots branch August 12, 2026 03:09
@github-project-automation github-project-automation Bot moved this from In Progress to Done in event-sorcery Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Haskell cannot resume entities from snapshots

1 participant