-
-
Notifications
You must be signed in to change notification settings - Fork 11
feat: Add Entity Fetcher Service for Event Sourcing #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
24f0bff
f5b244f
7e21bda
7d7d0fd
a801011
abda624
ec58786
fd70254
9723c52
fd8c98a
402a246
cea2180
baa0f3c
44771aa
6223162
4525a8d
44c49cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| module Service.EntityFetcher ( | ||
| module Reexported, | ||
| ) where | ||
|
|
||
| import Service.EntityFetcher.Core as Reexported |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| module Service.EntityFetcher.Core ( | ||
| EntityFetcher (..), | ||
| Error (..), | ||
| new, | ||
| ) where | ||
|
|
||
| import Core | ||
| import Service.Event (EntityName, Event (..), StreamId) | ||
| import Service.EventStore.Core (EventStore) | ||
| import Service.EventStore.Core qualified as EventStore | ||
| import Stream qualified | ||
| import Task qualified | ||
|
|
||
|
|
||
| -- | Errors that can occur during entity operations | ||
| data Error | ||
| = EventStoreError EventStore.Error | ||
| | ReductionError Text | ||
| deriving (Eq, Show) | ||
|
|
||
|
|
||
| -- | An entity fetcher is responsible for fetching an entity's current state | ||
| -- by reading all events from the event store and applying a reduction function. | ||
| -- | ||
| -- The fetcher takes an initial state and a reduce function that applies events | ||
| -- to update the state. | ||
| data EntityFetcher state event = EntityFetcher | ||
| { -- | Fetch an entity's current state by entity name and stream ID. | ||
| -- Reads all events from the event store and applies the reduction function | ||
| -- to compute the current state. | ||
| fetch :: EntityName -> StreamId -> Task Error state | ||
| } | ||
|
|
||
|
|
||
| -- | Create a new entity fetcher with the given event store, initial state, and reduce function. | ||
| -- | ||
| -- The reduce function takes an event and the current state, and returns the new state | ||
| -- after applying the event. | ||
| -- | ||
| -- Example: | ||
| -- | ||
| -- @ | ||
| -- fetcher <- EntityFetcher.new eventStore initialState applyEvent | ||
| -- state <- fetcher.fetch entityName streamId | ||
| -- @ | ||
| new :: | ||
| forall state event. | ||
| EventStore event -> | ||
| state -> | ||
| (event -> state -> state) -> | ||
| Task Error (EntityFetcher state event) | ||
| new eventStore initialState reduceFunction = do | ||
| let fetchImpl entityName streamId = do | ||
| -- Read all events for this entity stream | ||
| streamMessages <- | ||
| eventStore.readAllStreamEvents entityName streamId | ||
| |> Task.mapError EventStoreError | ||
|
|
||
| -- Consume the stream, applying the reduction function to each event | ||
| -- This processes events one-by-one without loading everything into memory | ||
| finalState <- | ||
| streamMessages | ||
| |> Stream.consume | ||
| ( \state message -> do | ||
| case message of | ||
| -- Only process actual events, ignore other message types | ||
| EventStore.StreamEvent event -> do | ||
| let eventData = event.event | ||
| let newState = reduceFunction eventData state | ||
| Task.yield newState | ||
| -- For all other message types, keep state unchanged | ||
| _ -> Task.yield state | ||
| ) | ||
| initialState | ||
| |> Task.mapError (\errorText -> EventStoreError (EventStore.StorageFailure errorText)) | ||
|
|
||
| Task.yield finalState | ||
|
|
||
| Task.yield | ||
| EntityFetcher | ||
| { fetch = fetchImpl | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| module Test.Service.EntityFetcher where | ||
|
|
||
| import Core | ||
| import Service.EntityFetcher.Core (EntityFetcher) | ||
| import Service.EventStore.Core (EventStore) | ||
| import Test | ||
| import Test.Service.EntityFetcher.Core (BankAccountEvent, BankAccountState) | ||
| import Test.Service.EntityFetcher.Fetch.Spec qualified as Fetch | ||
|
|
||
|
|
||
| spec :: | ||
| Task Text (EventStore BankAccountEvent, EntityFetcher BankAccountState BankAccountEvent) -> Spec Unit | ||
| spec newStoreAndFetcher = do | ||
| describe "Entity Fetcher Specification Tests" do | ||
| Fetch.spec newStoreAndFetcher |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| module Test.Service.EntityFetcher.Core ( | ||
| BankAccountEvent (..), | ||
| BankAccountState (..), | ||
| initialState, | ||
| applyEvent, | ||
| newFetcher, | ||
| ) where | ||
|
|
||
| import Core | ||
| import Json qualified | ||
| import Service.EntityFetcher.Core (EntityFetcher) | ||
| import Service.EntityFetcher.Core qualified as EntityFetcher | ||
| import Service.EventStore.Core (EventStore) | ||
| import Test.Service.EventStore.Core (BankAccountEvent (..)) | ||
|
|
||
|
|
||
| -- | Example entity state for a bank account | ||
| data BankAccountState = BankAccountState | ||
| { balance :: Int, | ||
| isOpen :: Bool, | ||
| version :: Int | ||
| } | ||
| deriving (Eq, Show, Ord, Generic) | ||
|
|
||
|
|
||
| instance Json.ToJSON BankAccountState | ||
|
|
||
|
|
||
| instance Json.FromJSON BankAccountState | ||
|
|
||
|
|
||
| -- | Initial state for a new bank account | ||
| initialState :: BankAccountState | ||
| initialState = | ||
| BankAccountState | ||
| { balance = 0, | ||
| isOpen = False, | ||
| version = 0 | ||
| } | ||
|
|
||
|
|
||
| -- | Apply a bank account event to the current state | ||
| applyEvent :: BankAccountEvent -> BankAccountState -> BankAccountState | ||
| applyEvent event state = do | ||
| let newVersion = state.version + 1 | ||
| case event of | ||
| AccountOpened {initialBalance} -> | ||
| BankAccountState | ||
| { balance = initialBalance, | ||
| isOpen = True, | ||
| version = newVersion | ||
| } | ||
| MoneyDeposited {amount} -> | ||
| state | ||
| { balance = state.balance + amount, | ||
| version = newVersion | ||
| } | ||
| MoneyWithdrawn {amount} -> | ||
| state | ||
| { balance = state.balance - amount, | ||
| version = newVersion | ||
| } | ||
| AccountClosed -> | ||
| state | ||
| { isOpen = False, | ||
| version = newVersion | ||
| } | ||
|
|
||
|
|
||
| -- | Create a new entity fetcher for bank accounts | ||
| newFetcher :: EventStore BankAccountEvent -> Task EntityFetcher.Error (EntityFetcher BankAccountState BankAccountEvent) | ||
| newFetcher eventStore = do | ||
| EntityFetcher.new eventStore initialState applyEvent |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| module Test.Service.EntityFetcher.Fetch.Context ( | ||
| Context (..), | ||
| initialize, | ||
| ) where | ||
|
|
||
| import Core | ||
| import Service.EntityFetcher.Core (EntityFetcher) | ||
| import Service.Event qualified as Event | ||
| import Service.Event.StreamId qualified as StreamId | ||
| import Service.EventStore (EventStore) | ||
| import Test.Service.EntityFetcher.Core (BankAccountEvent, BankAccountState) | ||
|
|
||
|
|
||
| data Context = Context | ||
| { store :: EventStore BankAccountEvent, | ||
| fetcher :: EntityFetcher BankAccountState BankAccountEvent, | ||
| streamId :: Event.StreamId, | ||
| entityName :: Event.EntityName | ||
| } | ||
|
|
||
|
|
||
| initialize :: | ||
| Task Text (EventStore BankAccountEvent, EntityFetcher BankAccountState BankAccountEvent) -> | ||
| Task Text Context | ||
| initialize newStoreAndFetcher = do | ||
| (store, fetcher) <- newStoreAndFetcher | ||
| streamId <- StreamId.new | ||
| let entityName = Event.EntityName "BankAccount" | ||
| pure Context {store, fetcher, streamId, entityName} | ||
|
Comment on lines
+14
to
+29
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Avoid hard‑coding the BankAccount entity name in multiple places Hard‑coding 🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Error type's semantic boundaries require clarification.
The ReductionError constructor wraps Text, yet at line 75 it captures storage failures during stream consumption. This creates semantic ambiguity—is it a reduction error or a storage error? Consider either:
🤖 Prompt for AI Agents