├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── aggregate ├── event.go ├── executor.go ├── executor_test.go ├── root.go ├── root_test.go ├── store.go └── store_test.go ├── ambar ├── ambar.go ├── ambar_test.go ├── echoambar │ ├── echo.go │ └── echo_test.go └── testutil │ └── ambar.go ├── event.go ├── eventstore.go ├── eventstore_test.go ├── example ├── README.md ├── account │ ├── account.go │ ├── events.go │ └── id.go ├── cmd │ ├── ambar_projections │ │ └── main.go │ ├── api │ │ └── main.go │ └── projections │ │ └── main.go ├── go.mod └── go.sum ├── go.mod ├── go.sum ├── json_encoder.go ├── json_encoder_test.go ├── projection.go └── projection_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.23 19 | 20 | - name: Test 21 | run: | 22 | go mod tidy 23 | go test -race -withpg -v . 24 | go test -race -covermode=atomic -coverprofile=coverage.out -v `go list ./... | grep -v ./example` 25 | 26 | - name: Install goveralls 27 | run: go install github.com/mattn/goveralls@latest 28 | 29 | - name: Goveralls 30 | env: 31 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | run: goveralls -coverprofile=coverage.out -service=github 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | example.db 4 | accounts.json 5 | fly.toml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Anes Hasicic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go EventStore 2 | 3 | [![Go](https://github.com/aneshas/eventstore/actions/workflows/go.yml/badge.svg?branch=master)](https://github.com/aneshas/eventstore/actions/workflows/go.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/aneshas/eventstore/badge.svg)](https://coveralls.io/github/aneshas/eventstore) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/aneshas/eventstore)](https://goreportcard.com/report/github.com/aneshas/eventstore) 6 | 7 | Embeddable SQL EventStore + Aggregate Abstraction written in Go using gorm as an underlying persistence mechanism meaning - it will work 8 | with `almost` (tested sqlite and postgres) whatever underlying database gorm will support (just use the respective gorm driver - sqlite and postgres provided). 9 | 10 | It is also equiped with a fault-tolerant projection system that can be used to build read models for testing purposes and is also ready for 11 | production workloads in combination with [Ambar.cloud](https://ambar.cloud/) using the provided ambar package. 12 | 13 | ## Features 14 | 15 | - Appending (saving) events to a particular stream 16 | - Reading events from the stream 17 | - Reading all events 18 | - Subscribing (streaming) all events from the event store (real-time - polling) 19 | - Aggregate root abstraction to manage rehydration and event application 20 | - Generic aggregate store implementation used to read and save aggregates (events) 21 | - Fault-tolerant projection system (Projector) which can be used to build read models for testing purposes 22 | - [Ambar.cloud](https://ambar.cloud/) data destination (projection) integration for production projection workloads - see [example](example/) 23 | 24 | ## Example 25 | 26 | I provided a simple [example](example/) that showcases basic usage with sqlite, or check out [cqrs clinic example](https://github.com/aneshas/cqrs-clinic) 27 | -------------------------------------------------------------------------------- /aggregate/event.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import "time" 4 | 5 | // Event represents a domain event 6 | type Event struct { 7 | ID string 8 | E any 9 | OccurredOn time.Time 10 | 11 | CausationEventID *string 12 | CorrelationEventID *string 13 | Meta map[string]string 14 | } 15 | -------------------------------------------------------------------------------- /aggregate/executor.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // NewExecutor creates a new executor for the given aggregate store. 8 | func NewExecutor[T Rooter](store *Store[T]) Executor[T] { 9 | return func(ctx context.Context, a T, f func(ctx context.Context) error) error { 10 | return Exec(ctx, store, a, f) 11 | } 12 | } 13 | 14 | // Executor is a helper function to load an aggregate from the store, execute a function and save the aggregate back to the store. 15 | type Executor[T Rooter] func(ctx context.Context, a T, f func(ctx context.Context) error) error 16 | 17 | // Exec is a helper function to load an aggregate from the store, execute a function and save the aggregate back to the store. 18 | func Exec[T Rooter](ctx context.Context, store *Store[T], a T, f func(ctx context.Context) error) error { 19 | err := store.ByID(ctx, a.StringID(), a) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | err = f(ctx) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | return store.Save(ctx, a) 30 | } 31 | -------------------------------------------------------------------------------- /aggregate/executor_test.go: -------------------------------------------------------------------------------- 1 | package aggregate_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aneshas/eventstore" 7 | "github.com/aneshas/eventstore/aggregate" 8 | "github.com/stretchr/testify/assert" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestShould_Load_And_Persist_Aggregate(t *testing.T) { 14 | var es eventStore 15 | 16 | store := aggregate.NewStore[*foo](&es) 17 | 18 | aggrID := "foo-1" 19 | 20 | es.storedEvents = []eventstore.StoredEvent{ 21 | { 22 | Event: fooEvent{ 23 | Foo: "foo-1", 24 | }, 25 | Meta: nil, 26 | ID: "event-id-1", 27 | Sequence: 1, 28 | Type: "fooEvent", 29 | CausationEventID: nil, 30 | CorrelationEventID: nil, 31 | StreamID: aggrID, 32 | StreamVersion: 1, 33 | OccurredOn: time.Time{}, 34 | }, 35 | } 36 | 37 | exec := aggregate.NewExecutor(store) 38 | 39 | var f foo 40 | 41 | f.ID = "foo-1" 42 | 43 | err := exec(context.Background(), &f, func(ctx context.Context) error { 44 | f.doMoreStuff() 45 | 46 | return nil 47 | }) 48 | 49 | assert.NoError(t, err) 50 | 51 | assert.Equal(t, es.storedEvents[0].Event.(fooEvent).Foo, aggrID) 52 | } 53 | 54 | func TestShould_Should_Report_Exec_Error(t *testing.T) { 55 | var es eventStore 56 | 57 | store := aggregate.NewStore[*foo](&es) 58 | 59 | aggrID := "foo-1" 60 | 61 | es.storedEvents = []eventstore.StoredEvent{ 62 | { 63 | Event: fooEvent{ 64 | Foo: "foo-1", 65 | }, 66 | Meta: nil, 67 | ID: "event-id-1", 68 | Sequence: 1, 69 | Type: "fooEvent", 70 | CausationEventID: nil, 71 | CorrelationEventID: nil, 72 | StreamID: aggrID, 73 | StreamVersion: 1, 74 | OccurredOn: time.Time{}, 75 | }, 76 | } 77 | 78 | exec := aggregate.NewExecutor(store) 79 | 80 | var f foo 81 | 82 | f.ID = "foo-1" 83 | 84 | wantErr := fmt.Errorf("error") 85 | 86 | err := exec(context.Background(), &f, func(ctx context.Context) error { 87 | return wantErr 88 | }) 89 | 90 | assert.ErrorIs(t, err, wantErr) 91 | } 92 | 93 | func TestShould_Report_AggregateNotFound_Error(t *testing.T) { 94 | var es eventStore 95 | 96 | store := aggregate.NewStore[*foo](&es) 97 | 98 | exec := aggregate.NewExecutor(store) 99 | 100 | var f foo 101 | 102 | f.ID = "foo-1" 103 | 104 | es.wantErr = eventstore.ErrStreamNotFound 105 | 106 | err := exec(context.Background(), &f, func(ctx context.Context) error { 107 | f.doMoreStuff() 108 | 109 | return nil 110 | }) 111 | 112 | assert.ErrorIs(t, err, aggregate.ErrAggregateNotFound) 113 | } 114 | -------------------------------------------------------------------------------- /aggregate/root.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "fmt" 5 | "github.com/google/uuid" 6 | "reflect" 7 | "time" 8 | ) 9 | 10 | var ( 11 | // ErrMissingAggregateEventHandler is returned when aggregate event handler is missing 12 | // On{EventName} method 13 | ErrMissingAggregateEventHandler = fmt.Errorf("missing aggregate event handler") 14 | 15 | // ErrAggregateRootNotAPointer is returned when supplied aggregate root is not a pointer 16 | ErrAggregateRootNotAPointer = fmt.Errorf("aggregate needs to be a pointer") 17 | 18 | // ErrAggregateRootNotRehydrated is returned when aggregate is not rehydrated (with Rehydrate method) 19 | ErrAggregateRootNotRehydrated = fmt.Errorf("aggregate needs to be rehydrated") 20 | ) 21 | 22 | // Rooter represents an aggregate root interface 23 | type Rooter interface { 24 | StringID() string 25 | Events() []Event 26 | Version() int 27 | Rehydrate(acc any, events ...Event) 28 | FirstEventID() string 29 | LastEventID() string 30 | } 31 | 32 | // Root represents reusable DDD Event Sourcing friendly Aggregate 33 | // base type which provides helpers for easy aggregate initialization and 34 | // event handler execution 35 | type Root[T fmt.Stringer] struct { 36 | ID T 37 | 38 | version int 39 | domainEvents []Event 40 | 41 | firstEventID string 42 | lastEventID string 43 | 44 | ptr reflect.Value 45 | } 46 | 47 | // LastEventID returns last event ID 48 | func (a *Root[T]) LastEventID() string { 49 | return a.lastEventID 50 | } 51 | 52 | // FirstEventID returns first event ID 53 | func (a *Root[T]) FirstEventID() string { 54 | return a.firstEventID 55 | } 56 | 57 | // StringID returns aggregate ID string 58 | func (a *Root[T]) StringID() string { 59 | return a.ID.String() 60 | } 61 | 62 | // Rehydrate is used to construct and rehydrate the aggregate from events 63 | func (a *Root[T]) Rehydrate(aggregatePtr any, events ...Event) { 64 | a.ptr = reflect.ValueOf(aggregatePtr) 65 | 66 | if a.ptr.Kind() != reflect.Ptr { 67 | panic(ErrAggregateRootNotAPointer) 68 | } 69 | 70 | for _, evt := range events { 71 | a.mutate(evt) 72 | a.lastEventID = evt.ID 73 | 74 | a.version++ 75 | } 76 | } 77 | 78 | // Version returns current version of the aggregate (incremented every time 79 | // Apply is successfully called) 80 | func (a *Root[T]) Version() int { return a.version } 81 | 82 | // Events returns uncommitted domain events (produced by calling Apply) 83 | func (a *Root[T]) Events() []Event { 84 | if a.domainEvents == nil { 85 | return []Event{} 86 | } 87 | 88 | return a.domainEvents 89 | } 90 | 91 | // Apply mutates aggregate (calls respective event handle) and 92 | // appends event to internal slice, so that they can be retrieved with Events method 93 | // In order for Apply to work the derived aggregate struct needs to implement 94 | // an event handler method for all events it produces eg: 95 | // 96 | // If it produces event of type: SomethingImportantHappened 97 | // Derived aggregate should have the following method implemented: 98 | // func (a *SomeAggregate) OnSomethingImportantHappened(event SomethingImportantHappened) error 99 | // or 100 | // func (a *SomeAggregate) OnSomethingImportantHappened(event SomethingImportantHappened, extra aggregate.Event) error 101 | func (a *Root[T]) Apply(events ...any) { 102 | if !a.ptr.IsValid() { 103 | panic(ErrAggregateRootNotRehydrated) 104 | } 105 | 106 | for _, evt := range events { 107 | e := Event{ 108 | ID: uuid.Must(uuid.NewV7()).String(), 109 | E: evt, 110 | OccurredOn: time.Now().UTC(), 111 | } 112 | 113 | a.mutate(e) 114 | a.appendEvent(e) 115 | } 116 | } 117 | 118 | // ApplyWithID applies single event and mutates aggregate (calls respective event handle) and sets event ID explicitly. 119 | // See Apply for more details. 120 | func (a *Root[T]) ApplyWithID(eventID string, event any) { 121 | if !a.ptr.IsValid() { 122 | panic(ErrAggregateRootNotRehydrated) 123 | } 124 | 125 | e := Event{ 126 | ID: eventID, 127 | E: event, 128 | OccurredOn: time.Now().UTC(), 129 | } 130 | 131 | a.mutate(e) 132 | a.appendEvent(e) 133 | } 134 | 135 | func (a *Root[T]) mutate(evt Event) { 136 | ev := reflect.TypeOf(evt.E) 137 | 138 | hName := fmt.Sprintf("On%s", ev.Name()) 139 | 140 | h := a.ptr.MethodByName(hName) 141 | 142 | if !h.IsValid() { 143 | panic(ErrMissingAggregateEventHandler) 144 | } 145 | 146 | if a.firstEventID == "" { 147 | a.firstEventID = evt.ID 148 | } 149 | 150 | if h.Type().NumIn() == 2 { 151 | h.Call([]reflect.Value{ 152 | reflect.ValueOf(evt.E), 153 | reflect.ValueOf(evt), 154 | }) 155 | 156 | return 157 | } 158 | 159 | h.Call([]reflect.Value{ 160 | reflect.ValueOf(evt.E), 161 | }) 162 | } 163 | 164 | func (a *Root[T]) appendEvent(evt Event) { 165 | a.domainEvents = append(a.domainEvents, evt) 166 | } 167 | -------------------------------------------------------------------------------- /aggregate/root_test.go: -------------------------------------------------------------------------------- 1 | package aggregate_test 2 | 3 | import ( 4 | "errors" 5 | "github.com/aneshas/eventstore/aggregate" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | type created struct { 11 | name string 12 | email string 13 | } 14 | 15 | type nameUpdated struct { 16 | newName string 17 | } 18 | 19 | type emailChanged struct { 20 | newEmail string 21 | } 22 | 23 | type wrongHandler struct{} 24 | type missingHandler struct{} 25 | 26 | type id string 27 | 28 | // String implements fmt.Stringer 29 | func (id) String() string { return "id" } 30 | 31 | type testAggregate struct { 32 | aggregate.Root[id] 33 | 34 | name string 35 | email string 36 | eventID string 37 | } 38 | 39 | func (ta *testAggregate) Oncreated(event created) { 40 | ta.name = event.Name() 41 | ta.email = event.Email() 42 | } 43 | 44 | func (ta *testAggregate) OnnameUpdated(event nameUpdated) { 45 | ta.name = event.NewName() 46 | } 47 | 48 | func (ta *testAggregate) OnemailChanged(event emailChanged, extra aggregate.Event) { 49 | ta.email = event.newEmail 50 | ta.eventID = extra.ID 51 | } 52 | 53 | var errTest = errors.New("an error") 54 | 55 | func (ta *testAggregate) OnwrongHandler(event wrongHandler, n int) { 56 | } 57 | 58 | func (e *created) Name() string { return e.name } 59 | func (e *created) Email() string { return e.email } 60 | 61 | func (e *nameUpdated) NewName() string { return e.newName } 62 | 63 | func TestApplyEventShouldMutateAggregateAndAddEvent(t *testing.T) { 64 | var a testAggregate 65 | 66 | a.Rehydrate(&a) 67 | 68 | a.Apply(created{"john", "john@email.com"}) 69 | a.Apply(nameUpdated{"max"}) 70 | 71 | events := a.Events() 72 | 73 | if len(events) != 2 { 74 | t.Errorf("event count should be 2") 75 | } 76 | 77 | if a.name != "max" || a.email != "john@email.com" { 78 | t.Errorf("aggregate not mutated") 79 | } 80 | } 81 | 82 | func TestApplyEventShouldMutateAggregate_WithExtraEventDetails(t *testing.T) { 83 | var a testAggregate 84 | 85 | a.Rehydrate(&a) 86 | 87 | newEmail := "mail@mail.com" 88 | eventID := "manually-set-event-id" 89 | 90 | a.ApplyWithID(eventID, emailChanged{newEmail: newEmail}) 91 | 92 | assert.Equal(t, newEmail, a.email) 93 | assert.Equal(t, eventID, a.eventID) 94 | } 95 | 96 | func TestShouldInitAggregate(t *testing.T) { 97 | var a testAggregate 98 | 99 | a.Rehydrate( 100 | &a, 101 | aggregate.Event{E: created{"john", "john@email.com"}}, 102 | aggregate.Event{E: nameUpdated{"max"}}, 103 | ) 104 | 105 | a.Apply(nameUpdated{"jane"}) 106 | 107 | if a.name != "jane" || a.email != "john@email.com" { 108 | t.Errorf("aggregate not mutated") 109 | } 110 | } 111 | 112 | func TestShouldPanicOnApplyWithNoRehydrate(t *testing.T) { 113 | defer func() { 114 | r := recover() 115 | 116 | if r == nil { 117 | t.Errorf("should") 118 | } 119 | 120 | err, ok := r.(error) 121 | 122 | if !ok { 123 | t.Errorf("should panic with error") 124 | } 125 | 126 | if !errors.Is(err, aggregate.ErrAggregateRootNotRehydrated) { 127 | t.Errorf("should panic with not rehydrated error") 128 | } 129 | }() 130 | 131 | var a testAggregate 132 | 133 | a.Apply(missingHandler{}) 134 | } 135 | 136 | func TestShouldPanicOnApplyWithIDWithNoRehydrate(t *testing.T) { 137 | defer func() { 138 | r := recover() 139 | 140 | if r == nil { 141 | t.Errorf("should") 142 | } 143 | 144 | err, ok := r.(error) 145 | 146 | if !ok { 147 | t.Errorf("should panic with error") 148 | } 149 | 150 | if !errors.Is(err, aggregate.ErrAggregateRootNotRehydrated) { 151 | t.Errorf("should panic with not rehydrated error") 152 | } 153 | }() 154 | 155 | var a testAggregate 156 | 157 | a.ApplyWithID("id", missingHandler{}) 158 | } 159 | 160 | func TestShouldPanicOnMissingHandler(t *testing.T) { 161 | defer func() { 162 | r := recover() 163 | 164 | if r == nil { 165 | t.Errorf("should") 166 | } 167 | 168 | err, ok := r.(error) 169 | 170 | if !ok { 171 | t.Errorf("should panic with error") 172 | } 173 | 174 | if !errors.Is(err, aggregate.ErrMissingAggregateEventHandler) { 175 | t.Errorf("should panic with missing handler error") 176 | } 177 | }() 178 | 179 | var a testAggregate 180 | 181 | a.Rehydrate(&a) 182 | 183 | a.Apply(missingHandler{}) 184 | } 185 | 186 | func TestShouldAcceptOnlyPointerOnRehydration(t *testing.T) { 187 | defer func() { 188 | r := recover() 189 | 190 | if r == nil { 191 | t.Errorf("should panic") 192 | } 193 | 194 | err, ok := r.(error) 195 | 196 | if !ok { 197 | t.Errorf("should panic with error") 198 | } 199 | 200 | if !errors.Is(err, aggregate.ErrAggregateRootNotAPointer) { 201 | t.Errorf("should panic with pointer error") 202 | } 203 | }() 204 | 205 | var a testAggregate 206 | 207 | a.Rehydrate(a) 208 | } 209 | -------------------------------------------------------------------------------- /aggregate/store.go: -------------------------------------------------------------------------------- 1 | package aggregate 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/aneshas/eventstore" 7 | ) 8 | 9 | // ErrAggregateNotFound is returned when aggregate is not found 10 | var ErrAggregateNotFound = errors.New("aggregate not found") 11 | 12 | type metaKey struct{} 13 | 14 | type correlationIDKey struct{} 15 | 16 | type causationIDKey struct{} 17 | 18 | // NewStore constructs new event sourced aggregate store 19 | func NewStore[T Rooter](eventStore EventStore) *Store[T] { 20 | return &Store[T]{ 21 | eventStore: eventStore, 22 | } 23 | } 24 | 25 | // EventStore represents event store 26 | type EventStore interface { 27 | AppendStream(ctx context.Context, id string, version int, events []eventstore.EventToStore) error 28 | ReadStream(ctx context.Context, id string) ([]eventstore.StoredEvent, error) 29 | } 30 | 31 | // Store represents event sourced aggregate store 32 | type Store[T Rooter] struct { 33 | eventStore EventStore 34 | } 35 | 36 | // Save saves aggregate events to the event store 37 | func (s *Store[T]) Save(ctx context.Context, aggregate T) error { 38 | var ( 39 | events []eventstore.EventToStore 40 | meta map[string]string 41 | correlationID string 42 | causationID string 43 | ) 44 | 45 | if v, ok := ctx.Value(metaKey{}).(map[string]string); ok { 46 | meta = v 47 | } 48 | 49 | if v, ok := ctx.Value(correlationIDKey{}).(string); ok { 50 | correlationID = v 51 | } 52 | 53 | if v, ok := ctx.Value(causationIDKey{}).(string); ok { 54 | causationID = v 55 | } 56 | 57 | if correlationID == "" { 58 | correlationID = aggregate.FirstEventID() 59 | } 60 | 61 | if causationID == "" { 62 | causationID = aggregate.LastEventID() 63 | } 64 | 65 | for _, evt := range aggregate.Events() { 66 | if causationID == "" { 67 | causationID = evt.ID 68 | } 69 | 70 | events = append(events, eventstore.EventToStore{ 71 | Event: evt.E, 72 | ID: evt.ID, 73 | OccurredOn: evt.OccurredOn, 74 | CausationEventID: causationID, 75 | CorrelationEventID: correlationID, 76 | Meta: meta, 77 | }) 78 | 79 | causationID = evt.ID 80 | } 81 | 82 | return s.eventStore.AppendStream( 83 | ctx, 84 | aggregate.StringID(), 85 | aggregate.Version(), 86 | events, 87 | ) 88 | } 89 | 90 | // ByID finds aggregate events by its stream id and rehydrates the aggregate 91 | func (s *Store[T]) ByID(ctx context.Context, id string, root T) error { 92 | storedEvents, err := s.eventStore.ReadStream(ctx, id) 93 | if err != nil { 94 | if errors.Is(err, eventstore.ErrStreamNotFound) { 95 | return ErrAggregateNotFound 96 | } 97 | 98 | return err 99 | } 100 | 101 | var events []Event 102 | 103 | for _, evt := range storedEvents { 104 | events = append(events, Event{ 105 | ID: evt.ID, 106 | E: evt.Event, 107 | OccurredOn: evt.OccurredOn, 108 | CausationEventID: evt.CausationEventID, 109 | CorrelationEventID: evt.CorrelationEventID, 110 | Meta: evt.Meta, 111 | }) 112 | } 113 | 114 | root.Rehydrate(root, events...) 115 | 116 | return nil 117 | } 118 | 119 | // CtxWithMeta returns new context with meta data 120 | func CtxWithMeta(ctx context.Context, meta map[string]string) context.Context { 121 | return context.WithValue(ctx, metaKey{}, meta) 122 | } 123 | 124 | // CtxWithCorrelationID returns new context with correlation ID 125 | func CtxWithCorrelationID(ctx context.Context, id string) context.Context { 126 | return context.WithValue(ctx, correlationIDKey{}, id) 127 | } 128 | 129 | // CtxWithCausationID returns new context with causation ID 130 | func CtxWithCausationID(ctx context.Context, id string) context.Context { 131 | return context.WithValue(ctx, causationIDKey{}, id) 132 | } 133 | -------------------------------------------------------------------------------- /aggregate/store_test.go: -------------------------------------------------------------------------------- 1 | package aggregate_test 2 | 3 | import ( 4 | "context" 5 | "github.com/aneshas/eventstore" 6 | "github.com/aneshas/eventstore/aggregate" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | type eventStore struct { 13 | eventsToStore []eventstore.EventToStore 14 | id string 15 | ctx context.Context 16 | version int 17 | 18 | storedEvents []eventstore.StoredEvent 19 | 20 | wantErr error 21 | } 22 | 23 | // AppendStream appends events to the stream 24 | func (e *eventStore) AppendStream(ctx context.Context, id string, version int, events []eventstore.EventToStore) error { 25 | for i := range events { 26 | events[i].OccurredOn = time.Time{} 27 | } 28 | 29 | e.eventsToStore = events 30 | e.id = id 31 | e.version = version 32 | e.ctx = ctx 33 | 34 | return nil 35 | } 36 | 37 | // ReadStream reads events from the stream 38 | func (e *eventStore) ReadStream(_ context.Context, _ string) ([]eventstore.StoredEvent, error) { 39 | if e.wantErr != nil { 40 | return nil, e.wantErr 41 | } 42 | 43 | return e.storedEvents, nil 44 | } 45 | 46 | type fooEvent struct { 47 | Foo string 48 | } 49 | 50 | // ID represents an ID 51 | type ID string 52 | 53 | func (id ID) String() string { 54 | return string(id) 55 | } 56 | 57 | type foo struct { 58 | aggregate.Root[ID] 59 | 60 | Balance int 61 | } 62 | 63 | func (f *foo) doStuff() { 64 | f.Apply( 65 | fooEvent{ 66 | Foo: "foo-1", 67 | }, 68 | fooEvent{ 69 | Foo: "foo-2", 70 | }, 71 | ) 72 | } 73 | 74 | func (f *foo) doMoreStuff() { 75 | f.Apply( 76 | fooEvent{ 77 | Foo: "foo-1", 78 | }, 79 | ) 80 | } 81 | 82 | // OnFooEvent handler 83 | func (f *foo) OnfooEvent(evt fooEvent) { 84 | f.ID = ID(evt.Foo) 85 | } 86 | 87 | func TestShould_Save_Aggregate_Events(t *testing.T) { 88 | var es eventStore 89 | 90 | store := aggregate.NewStore[*foo](&es) 91 | 92 | meta := map[string]string{ 93 | "foo": "bar", 94 | } 95 | 96 | ctx := aggregate.CtxWithMeta(context.Background(), meta) 97 | 98 | var f foo 99 | 100 | f.Rehydrate(&f) 101 | f.doStuff() 102 | 103 | err := store.Save(ctx, &f) 104 | 105 | assert.NoError(t, err) 106 | 107 | assert.Equal(t, meta, es.eventsToStore[0].Meta) 108 | 109 | assert.Equal(t, ctx, es.ctx) 110 | assert.Equal(t, 0, es.version) 111 | assert.Equal(t, "foo-2", es.id) 112 | 113 | events := f.Events() 114 | 115 | assert.Equal(t, []eventstore.EventToStore{ 116 | { 117 | Event: fooEvent{ 118 | Foo: "foo-1", 119 | }, 120 | ID: events[0].ID, 121 | CausationEventID: events[0].ID, 122 | CorrelationEventID: events[0].ID, 123 | Meta: meta, 124 | OccurredOn: time.Time{}, 125 | }, 126 | { 127 | Event: fooEvent{ 128 | Foo: "foo-2", 129 | }, 130 | ID: events[1].ID, 131 | CausationEventID: events[0].ID, 132 | CorrelationEventID: events[0].ID, 133 | Meta: meta, 134 | OccurredOn: time.Time{}, 135 | }, 136 | }, es.eventsToStore) 137 | } 138 | 139 | func TestShould_Save_Aggregate_Events_With_Explicit_CorrelationIDs(t *testing.T) { 140 | var es eventStore 141 | 142 | store := aggregate.NewStore[*foo](&es) 143 | 144 | meta := map[string]string{ 145 | "foo": "bar", 146 | } 147 | 148 | ctx := aggregate.CtxWithMeta(context.Background(), meta) 149 | ctx = aggregate.CtxWithCausationID(ctx, "some-causation-event-id") 150 | ctx = aggregate.CtxWithCorrelationID(ctx, "some-correlation-event-id") 151 | 152 | var f foo 153 | 154 | f.Rehydrate(&f) 155 | f.doStuff() 156 | 157 | err := store.Save(ctx, &f) 158 | 159 | assert.NoError(t, err) 160 | 161 | events := f.Events() 162 | 163 | assert.Equal(t, []eventstore.EventToStore{ 164 | { 165 | Event: fooEvent{ 166 | Foo: "foo-1", 167 | }, 168 | ID: events[0].ID, 169 | CausationEventID: "some-causation-event-id", 170 | CorrelationEventID: "some-correlation-event-id", 171 | Meta: meta, 172 | OccurredOn: time.Time{}, 173 | }, 174 | { 175 | Event: fooEvent{ 176 | Foo: "foo-2", 177 | }, 178 | ID: events[1].ID, 179 | CausationEventID: events[0].ID, 180 | CorrelationEventID: "some-correlation-event-id", 181 | Meta: meta, 182 | OccurredOn: time.Time{}, 183 | }, 184 | }, es.eventsToStore) 185 | } 186 | 187 | func TestShould_Return_AggregateNotFound_Error_If_No_Events(t *testing.T) { 188 | var es eventStore 189 | 190 | es.wantErr = eventstore.ErrStreamNotFound 191 | 192 | var f foo 193 | 194 | store := aggregate.NewStore[*foo](&es) 195 | 196 | err := store.ByID(context.Background(), "", &f) 197 | 198 | assert.ErrorIs(t, err, aggregate.ErrAggregateNotFound) 199 | } 200 | 201 | func TestShould_Rehydrate_Aggregate(t *testing.T) { 202 | var es eventStore 203 | 204 | var f foo 205 | 206 | store := aggregate.NewStore[*foo](&es) 207 | 208 | es.storedEvents = []eventstore.StoredEvent{ 209 | { 210 | Event: fooEvent{ 211 | Foo: "foo-1", 212 | }, 213 | Meta: nil, 214 | ID: "event-id-1", 215 | Sequence: 1, 216 | Type: "fooEvent", 217 | CausationEventID: nil, 218 | CorrelationEventID: nil, 219 | StreamID: "foo-1", 220 | StreamVersion: 1, 221 | OccurredOn: time.Time{}, 222 | }, 223 | { 224 | Event: fooEvent{ 225 | Foo: "foo-1", 226 | }, 227 | Meta: nil, 228 | ID: "event-id-1", 229 | Sequence: 1, 230 | Type: "fooEvent", 231 | CausationEventID: nil, 232 | CorrelationEventID: nil, 233 | StreamID: "foo-2", 234 | StreamVersion: 1, 235 | OccurredOn: time.Time{}, 236 | }, 237 | } 238 | 239 | err := store.ByID(context.Background(), "", &f) 240 | 241 | assert.NoError(t, err) 242 | assert.Equal(t, "foo-1", f.StringID()) 243 | assert.Equal(t, 2, f.Version()) 244 | assert.Len(t, f.Events(), 0) 245 | } 246 | -------------------------------------------------------------------------------- /ambar/ambar.go: -------------------------------------------------------------------------------- 1 | package ambar 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "github.com/aneshas/eventstore" 7 | "github.com/relvacode/iso8601" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | var ( 13 | // ErrNoRetry is the error returned when we don't want to retry 14 | // projecting events in case of an error. 15 | // This is also the default behavior when an error is returned but this 16 | // error can be used if we also want to wrap the error eg. for logging 17 | ErrNoRetry = errors.New("retry") 18 | 19 | // ErrKeepItGoing is the error returned when we want to keep projecting 20 | // events in case of an error 21 | ErrKeepItGoing = errors.New("keep it going") 22 | ) 23 | 24 | // SuccessResp is the success response 25 | // https://docs.ambar.cloud/#Data%20Destinations 26 | var SuccessResp = `{ 27 | "result": { 28 | "success": {} 29 | } 30 | }` 31 | 32 | // RetryResp is the retry response 33 | // https://docs.ambar.cloud/#Data%20Destinations 34 | var RetryResp = `{ 35 | "result": { 36 | "error": { 37 | "policy": "must_retry", 38 | "class": "must retry it", 39 | "description": "must retry it" 40 | } 41 | } 42 | }` 43 | 44 | // KeepGoingResp is the keep going response 45 | // https://docs.ambar.cloud/#Data%20Destinations 46 | var KeepGoingResp = `{ 47 | "result": { 48 | "error": { 49 | "policy": "keep_going", 50 | "class": "keep it going", 51 | "description": "keep it going" 52 | } 53 | } 54 | }` 55 | 56 | // New constructs a new Ambar projection handler 57 | func New(dec Decoder) *Ambar { 58 | return &Ambar{dec: dec} 59 | } 60 | 61 | // Decoder is an interface for decoding events 62 | type Decoder interface { 63 | Decode(*eventstore.EncodedEvt) (any, error) 64 | } 65 | 66 | // Ambar is a projection handler for ambar events 67 | type Ambar struct { 68 | dec Decoder 69 | } 70 | 71 | // Req is the ambar projection request 72 | type Req struct { 73 | Payload Payload `json:"payload"` 74 | } 75 | 76 | // Payload is the ambar projection request payload 77 | type Payload struct { 78 | Event string `json:"data"` 79 | Meta *string `json:"meta"` 80 | ID string `json:"id"` 81 | Sequence uint64 `json:"sequence"` 82 | Type string `json:"type"` 83 | CausationEventID *string `json:"causation_event_id"` 84 | CorrelationEventID *string `json:"correlation_event_id"` 85 | StreamID string `json:"stream_id"` 86 | StreamVersion int `json:"stream_version"` 87 | OccurredOn string `json:"occurred_on"` 88 | } 89 | 90 | // Projection is an ambar projection function 91 | type Projection func(*http.Request, eventstore.StoredEvent) error 92 | 93 | // Project projects ambar event to provided projection 94 | // It will always return ambar retry policy error if deserialization fails 95 | func (a *Ambar) Project(r *http.Request, projection Projection, data []byte) error { 96 | var event Req 97 | 98 | err := json.Unmarshal(data, &event) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | decoded, err := a.dec.Decode(&eventstore.EncodedEvt{ 104 | Data: event.Payload.Event, 105 | Type: event.Payload.Type, 106 | }) 107 | if err != nil { 108 | if errors.Is(err, eventstore.ErrEventNotRegistered) { 109 | return nil 110 | } 111 | 112 | return err 113 | } 114 | 115 | var occurredOn time.Time 116 | 117 | if event.Payload.OccurredOn != "" { 118 | occurredOn, err = iso8601.ParseString(event.Payload.OccurredOn) 119 | if err != nil { 120 | return err 121 | } 122 | } 123 | 124 | var meta map[string]string 125 | 126 | if event.Payload.Meta != nil { 127 | err = json.Unmarshal([]byte(*event.Payload.Meta), &meta) 128 | if err != nil { 129 | return err 130 | } 131 | } 132 | 133 | return projection( 134 | r, 135 | eventstore.StoredEvent{ 136 | Event: decoded, 137 | ID: event.Payload.ID, 138 | Meta: meta, 139 | Sequence: event.Payload.Sequence, 140 | Type: event.Payload.Type, 141 | CausationEventID: event.Payload.CausationEventID, 142 | CorrelationEventID: event.Payload.CorrelationEventID, 143 | StreamID: event.Payload.StreamID, 144 | StreamVersion: event.Payload.StreamVersion, 145 | OccurredOn: occurredOn, 146 | }) 147 | } 148 | -------------------------------------------------------------------------------- /ambar/ambar_test.go: -------------------------------------------------------------------------------- 1 | package ambar_test 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aneshas/eventstore" 6 | "github.com/aneshas/eventstore/ambar" 7 | "github.com/aneshas/eventstore/ambar/testutil" 8 | "github.com/relvacode/iso8601" 9 | "github.com/stretchr/testify/assert" 10 | "net/http" 11 | "testing" 12 | ) 13 | 14 | func TestShould_Project_Required_Data(t *testing.T) { 15 | var a = ambar.New(eventstore.NewJSONEncoder(testutil.TestEvent{})) 16 | 17 | occurredOn, err := iso8601.ParseString(testutil.AmbarPayload.OccurredOn) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | projection := func(_ *http.Request, data eventstore.StoredEvent) error { 23 | assert.Equal(t, eventstore.StoredEvent{ 24 | Event: testutil.Event, 25 | Meta: nil, 26 | ID: testutil.AmbarPayload.ID, 27 | Sequence: testutil.AmbarPayload.Sequence, 28 | Type: "TestEvent", 29 | CausationEventID: nil, 30 | CorrelationEventID: nil, 31 | StreamID: testutil.AmbarPayload.StreamID, 32 | StreamVersion: testutil.AmbarPayload.StreamVersion, 33 | OccurredOn: occurredOn, 34 | }, data) 35 | 36 | return nil 37 | } 38 | 39 | err = a.Project(nil, projection, testutil.Payload(t, testutil.AmbarPayload)) 40 | 41 | assert.NoError(t, err) 42 | } 43 | 44 | func TestShould_Retry_On_Bad_Date_Format(t *testing.T) { 45 | p := testutil.AmbarPayload 46 | 47 | p.OccurredOn = "bad-date-time" 48 | 49 | var a = ambar.New(eventstore.NewJSONEncoder(testutil.TestEvent{})) 50 | 51 | err := a.Project(nil, nil, testutil.Payload(t, p)) 52 | 53 | assert.Error(t, err) 54 | } 55 | 56 | func TestShould_Retry_On_Bad_Meta_Format(t *testing.T) { 57 | p := testutil.AmbarPayload 58 | 59 | badMeta := "bad-meta" 60 | 61 | p.Meta = &badMeta 62 | 63 | var a = ambar.New(eventstore.NewJSONEncoder(testutil.TestEvent{})) 64 | 65 | err := a.Project(nil, nil, testutil.Payload(t, p)) 66 | 67 | assert.Error(t, err) 68 | } 69 | 70 | func TestShould_Not_Retry_On_Unregistered_Event(t *testing.T) { 71 | var a = ambar.New(eventstore.NewJSONEncoder()) 72 | 73 | err := a.Project(nil, nil, testutil.Payload(t, testutil.AmbarPayload)) 74 | 75 | assert.NoError(t, err) 76 | } 77 | 78 | func TestShould_Project_Optional_Data(t *testing.T) { 79 | p := testutil.AmbarPayload 80 | 81 | meta := map[string]string{ 82 | "foo": "bar", 83 | } 84 | 85 | metaData, err := json.Marshal(meta) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | 90 | metaStr := string(metaData) 91 | correlationEventID := "correlation-event-id" 92 | causationEventID := "causation-event-id" 93 | 94 | p.Meta = &metaStr 95 | p.CausationEventID = &causationEventID 96 | p.CorrelationEventID = &correlationEventID 97 | 98 | var a = ambar.New(eventstore.NewJSONEncoder(testutil.TestEvent{})) 99 | 100 | projection := func(_ *http.Request, data eventstore.StoredEvent) error { 101 | assert.Equal(t, correlationEventID, *data.CorrelationEventID) 102 | assert.Equal(t, causationEventID, *data.CausationEventID) 103 | assert.Equal(t, meta, data.Meta) 104 | 105 | return nil 106 | } 107 | 108 | err = a.Project(nil, projection, testutil.Payload(t, p)) 109 | 110 | assert.NoError(t, err) 111 | } 112 | -------------------------------------------------------------------------------- /ambar/echoambar/echo.go: -------------------------------------------------------------------------------- 1 | package echoambar 2 | 3 | import ( 4 | "errors" 5 | "github.com/aneshas/eventstore/ambar" 6 | "github.com/labstack/echo/v4" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | var _ Projector = (*ambar.Ambar)(nil) 12 | 13 | // Projector is an interface for projecting events 14 | type Projector interface { 15 | Project(r *http.Request, projection ambar.Projection, data []byte) error 16 | } 17 | 18 | // Wrap returns a func wrapper around Ambar projection handler which adapts it to echo.HandlerFunc 19 | func Wrap(a Projector) func(projection ambar.Projection) echo.HandlerFunc { 20 | return func(projection ambar.Projection) echo.HandlerFunc { 21 | return func(c echo.Context) error { 22 | r := c.Request() 23 | 24 | req, err := io.ReadAll(r.Body) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | err = a.Project(r, projection, req) 30 | if err != nil { 31 | if errors.Is(err, ambar.ErrNoRetry) { 32 | return c.JSONBlob(http.StatusOK, []byte(ambar.SuccessResp)) 33 | } 34 | 35 | if errors.Is(err, ambar.ErrKeepItGoing) { 36 | return c.JSONBlob(http.StatusOK, []byte(ambar.KeepGoingResp)) 37 | } 38 | 39 | return c.JSONBlob(http.StatusOK, []byte(ambar.RetryResp)) 40 | } 41 | 42 | return c.JSONBlob(http.StatusOK, []byte(ambar.SuccessResp)) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ambar/echoambar/echo_test.go: -------------------------------------------------------------------------------- 1 | package echoambar_test 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aneshas/eventstore/ambar" 6 | "github.com/aneshas/eventstore/ambar/echoambar" 7 | "github.com/labstack/echo/v4" 8 | "github.com/stretchr/testify/assert" 9 | "net/http" 10 | "net/http/httptest" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | var testPayload = `{ "payload": {}}` 16 | 17 | type projector struct { 18 | wantErr error 19 | data []byte 20 | } 21 | 22 | // Project projects ambar event to provided projection 23 | func (p *projector) Project(_ *http.Request, _ ambar.Projection, data []byte) error { 24 | p.data = data 25 | 26 | if p.wantErr != nil { 27 | return p.wantErr 28 | } 29 | 30 | return nil 31 | } 32 | 33 | func TestShould_Project_Successfully(t *testing.T) { 34 | var p projector 35 | 36 | rec, err := project(t, &p, testPayload) 37 | 38 | assert.NoError(t, err) 39 | assert.Equal(t, testPayload, string(p.data)) 40 | assert.Equal(t, http.StatusOK, rec.Code) 41 | assert.Equal(t, ambar.SuccessResp, rec.Body.String()) 42 | } 43 | 44 | func TestShould_Project_With_KeepItGoing(t *testing.T) { 45 | var p projector 46 | 47 | p.wantErr = ambar.ErrKeepItGoing 48 | 49 | rec, err := project(t, &p, testPayload) 50 | 51 | assert.NoError(t, err) 52 | assert.Equal(t, testPayload, string(p.data)) 53 | assert.Equal(t, http.StatusOK, rec.Code) 54 | assert.Equal(t, ambar.KeepGoingResp, rec.Body.String()) 55 | } 56 | 57 | func TestShould_Project_With_No_Retry(t *testing.T) { 58 | var p projector 59 | 60 | p.wantErr = ambar.ErrNoRetry 61 | 62 | rec, err := project(t, &p, testPayload) 63 | 64 | assert.NoError(t, err) 65 | assert.Equal(t, testPayload, string(p.data)) 66 | assert.Equal(t, http.StatusOK, rec.Code) 67 | assert.Equal(t, ambar.SuccessResp, rec.Body.String()) 68 | } 69 | 70 | func TestShould_Project_With_Retry(t *testing.T) { 71 | var p projector 72 | 73 | p.wantErr = fmt.Errorf("some arbitrary error") 74 | 75 | rec, err := project(t, &p, testPayload) 76 | 77 | assert.NoError(t, err) 78 | assert.Equal(t, testPayload, string(p.data)) 79 | assert.Equal(t, http.StatusOK, rec.Code) 80 | assert.Equal(t, ambar.RetryResp, rec.Body.String()) 81 | } 82 | 83 | func project(t *testing.T, p *projector, payload string) (*httptest.ResponseRecorder, error) { 84 | t.Helper() 85 | 86 | e := echo.New() 87 | req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(payload)) 88 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 89 | rec := httptest.NewRecorder() 90 | c := e.NewContext(req, rec) 91 | 92 | h := echoambar.Wrap(p)(nil) 93 | 94 | err := h(c) 95 | 96 | return rec, err 97 | } 98 | -------------------------------------------------------------------------------- /ambar/testutil/ambar.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aneshas/eventstore/ambar" 6 | "testing" 7 | ) 8 | 9 | // TestEvent is a test event 10 | type TestEvent struct { 11 | Foo string 12 | Bar string 13 | } 14 | 15 | // Event is an instance of a test event 16 | var Event = TestEvent{ 17 | Foo: "foo", 18 | Bar: "bar", 19 | } 20 | 21 | // AmbarPayload is a test payload 22 | var AmbarPayload = ambar.Payload{ 23 | Event: eventData(), 24 | Meta: nil, 25 | ID: "event-id", 26 | Sequence: 1, 27 | Type: "TestEvent", 28 | CausationEventID: nil, 29 | CorrelationEventID: nil, 30 | StreamID: "stream-id", 31 | StreamVersion: 1, 32 | OccurredOn: "2024-10-12T20:07:22.436271+00", 33 | } 34 | 35 | func eventData() string { 36 | data, err := json.Marshal(Event) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | return string(data) 42 | } 43 | 44 | // Payload creates a payload for testing 45 | func Payload(t *testing.T, p ambar.Payload) []byte { 46 | t.Helper() 47 | 48 | data, err := json.Marshal(ambar.Req{ 49 | Payload: p, 50 | }) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | return data 56 | } 57 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package eventstore 2 | 3 | import "time" 4 | 5 | // EventToStore represents an event that is to be stored in the event store 6 | type EventToStore struct { 7 | Event any 8 | 9 | // Optional 10 | ID string 11 | CausationEventID string 12 | CorrelationEventID string 13 | Meta map[string]string 14 | OccurredOn time.Time 15 | } 16 | 17 | // StoredEvent holds stored event data and meta data 18 | type StoredEvent struct { 19 | Event any 20 | Meta map[string]string 21 | 22 | ID string 23 | Sequence uint64 24 | Type string 25 | CausationEventID *string 26 | CorrelationEventID *string 27 | StreamID string 28 | StreamVersion int 29 | OccurredOn time.Time 30 | } 31 | -------------------------------------------------------------------------------- /eventstore.go: -------------------------------------------------------------------------------- 1 | // Package eventstore provides a simple light-weight event store implementation 2 | // that uses sqlite as a backing storage. 3 | // Apart from the event store, mechanisms for building projections and 4 | // working with aggregate roots are provided 5 | package eventstore 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "github.com/aneshas/tx/v2/gormtx" 13 | uuid2 "github.com/google/uuid" 14 | "gorm.io/driver/sqlite" 15 | "io" 16 | "time" 17 | 18 | "gorm.io/driver/postgres" 19 | "gorm.io/gorm" 20 | ) 21 | 22 | var ( 23 | // ErrEventNotRegistered indicates that the event is not registered with the encoder 24 | ErrEventNotRegistered = errors.New("event not registered with encoder") 25 | 26 | // ErrStreamNotFound indicates that the requested stream does not exist in the event store 27 | ErrStreamNotFound = errors.New("stream not found") 28 | 29 | // ErrConcurrencyCheckFailed indicates that stream entry related to a particular version already exists 30 | ErrConcurrencyCheckFailed = errors.New("optimistic concurrency check failed: stream version exists") 31 | 32 | // ErrSubscriptionClosedByClient is produced by sub.Err if client cancels the subscription using sub.Close() 33 | ErrSubscriptionClosedByClient = errors.New("subscription closed by client") 34 | ) 35 | 36 | // EncodedEvt represents encoded event used by a specific encoder implementation 37 | type EncodedEvt struct { 38 | Data string 39 | Type string 40 | } 41 | 42 | // Encoder is used by the event store in order to correctly marshal 43 | // and unmarshal event types 44 | type Encoder interface { 45 | Encode(any) (*EncodedEvt, error) 46 | Decode(*EncodedEvt) (any, error) 47 | } 48 | 49 | // New construct new event store 50 | // dbname - a path to sqlite database on disk 51 | // enc - a specific encoder implementation (see bundled JsonEncoder) 52 | func New(enc Encoder, opts ...Option) (*EventStore, error) { 53 | if enc == nil { 54 | return nil, fmt.Errorf("encoder implementation must be provided") 55 | } 56 | 57 | var cfg Cfg 58 | 59 | for _, opt := range opts { 60 | cfg = opt(cfg) 61 | } 62 | 63 | if cfg.PostgresDSN == "" && cfg.SQLitePath == "" { 64 | return nil, fmt.Errorf("either postgres dsn or sqlite path must be provided") 65 | } 66 | 67 | var dial gorm.Dialector 68 | 69 | if cfg.PostgresDSN != "" { 70 | dial = postgres.Open(cfg.PostgresDSN) 71 | } 72 | 73 | if cfg.SQLitePath != "" { 74 | dial = sqlite.Open(cfg.SQLitePath) 75 | } 76 | 77 | db, err := gorm.Open(dial, &gorm.Config{ 78 | TranslateError: true, 79 | }) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return &EventStore{ 85 | DB: db, 86 | enc: enc, 87 | }, db.AutoMigrate(&gormEvent{}) 88 | } 89 | 90 | // Cfg represents event store configuration 91 | type Cfg struct { 92 | PostgresDSN string 93 | SQLitePath string 94 | } 95 | 96 | // Option represents event store configuration option 97 | type Option func(Cfg) Cfg 98 | 99 | // WithPostgresDB is an event store option that can be used to configure 100 | // the eventstore to use postgres as a backing storage (pgx driver) 101 | func WithPostgresDB(dsn string) Option { 102 | return func(cfg Cfg) Cfg { 103 | cfg.PostgresDSN = dsn 104 | 105 | return cfg 106 | } 107 | } 108 | 109 | // WithSQLiteDB is an event store option that can be used to configure 110 | // the eventstore to use sqlite as a backing storage 111 | func WithSQLiteDB(path string) Option { 112 | return func(cfg Cfg) Cfg { 113 | cfg.SQLitePath = path 114 | 115 | return cfg 116 | } 117 | } 118 | 119 | // EventStore represents a sqlite event store implementation 120 | type EventStore struct { 121 | DB *gorm.DB 122 | enc Encoder 123 | } 124 | 125 | // Close should be called as a part of cleanup process 126 | // in order to close the underlying sql connection 127 | func (es *EventStore) Close() error { 128 | sqlDB, err := es.DB.DB() 129 | if err != nil { 130 | return err 131 | } 132 | 133 | return sqlDB.Close() 134 | } 135 | 136 | type gormEvent struct { 137 | ID string `gorm:"unique"` 138 | Sequence uint64 `gorm:"autoIncrement;primaryKey"` 139 | Type string `gorm:"index:event_store_idx_type"` 140 | Data string 141 | Meta *string 142 | CausationEventID *string `gorm:"index:event_store_idx_causation_id"` 143 | CorrelationEventID *string `gorm:"index:event_store_idx_correlation_id"` 144 | StreamID string `gorm:"index:event_store_idx_optimistic_check,unique;index"` 145 | StreamVersion int `gorm:"index:event_store_idx_optimistic_check,unique"` 146 | OccurredOn time.Time `gorm:"index:event_store_idx_occurred_on;autoCreateTime"` 147 | } 148 | 149 | // TableName returns gorm table name 150 | func (ge *gormEvent) TableName() string { return "event" } 151 | 152 | // AppendStreamConfig (configure using AppendStreamOpt) 153 | type AppendStreamConfig struct { 154 | meta map[string]string 155 | 156 | correlationEventID string 157 | causationEventID string 158 | 159 | // even event ids could be set eg - slice of event ids for each event 160 | } 161 | 162 | const ( 163 | // InitialStreamVersion can be used as an initial expectedVer for 164 | // new streams (as an argument to AppendStream) 165 | InitialStreamVersion int = 0 166 | ) 167 | 168 | // AppendStream will encode provided event slice and try to append them to 169 | // an indicated stream. If the stream does not exist it will be created. 170 | // If the stream already exists an optimistic concurrency check will be performed 171 | // using a compound key (stream-expectedVer). 172 | // expectedVer should be InitialStreamVersion for new streams and the latest 173 | // stream version for existing streams, otherwise a concurrency error 174 | // will be raised 175 | func (es *EventStore) AppendStream( 176 | ctx context.Context, 177 | stream string, 178 | expectedVer int, 179 | events []EventToStore) error { 180 | 181 | if len(stream) == 0 { 182 | return fmt.Errorf("stream name must be provided") 183 | } 184 | 185 | if expectedVer < InitialStreamVersion { 186 | return fmt.Errorf("expected version cannot be less than 0") 187 | } 188 | 189 | if len(events) == 0 { 190 | return nil 191 | } 192 | 193 | eventsToSave := make([]gormEvent, len(events)) 194 | 195 | for i, evt := range events { 196 | encoded, err := es.enc.Encode(evt.Event) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | expectedVer++ 202 | 203 | event := gormEvent{ 204 | ID: evt.ID, 205 | Type: encoded.Type, 206 | Data: encoded.Data, 207 | StreamID: stream, 208 | StreamVersion: expectedVer, 209 | OccurredOn: evt.OccurredOn, 210 | } 211 | 212 | if evt.CorrelationEventID != "" { 213 | event.CorrelationEventID = &evt.CorrelationEventID 214 | } 215 | 216 | if evt.CausationEventID != "" { 217 | event.CausationEventID = &evt.CausationEventID 218 | } 219 | 220 | if evt.Meta != nil { 221 | m, err := json.Marshal(evt.Meta) 222 | if err != nil { 223 | return err 224 | } 225 | 226 | ms := string(m) 227 | 228 | event.Meta = &ms 229 | } 230 | 231 | if event.ID == "" { 232 | uuid, err := uuid2.NewV7() 233 | if err != nil { 234 | return err 235 | } 236 | 237 | event.ID = uuid.String() 238 | } 239 | 240 | if !event.OccurredOn.IsZero() { 241 | event.OccurredOn = time.Now().UTC() 242 | } 243 | 244 | eventsToSave[i] = event 245 | } 246 | 247 | tx := es.conn(ctx).Create(&eventsToSave) 248 | 249 | if errors.Is(tx.Error, gorm.ErrDuplicatedKey) { 250 | return ErrConcurrencyCheckFailed 251 | } 252 | 253 | return tx.Error 254 | } 255 | 256 | func (es *EventStore) conn(ctx context.Context) *gorm.DB { 257 | tx, ok := gormtx.From(ctx) 258 | if ok { 259 | return tx.DB 260 | } 261 | 262 | return es.DB.WithContext(ctx) 263 | } 264 | 265 | // SubAllConfig (configure using SubAllOpt) 266 | type SubAllConfig struct { 267 | offset int 268 | batchSize int 269 | pollInterval time.Duration 270 | } 271 | 272 | // SubAllOpt represents subscribe to all events option 273 | type SubAllOpt func(SubAllConfig) SubAllConfig 274 | 275 | // WithOffset is a subscription / read all option that indicates an offset in 276 | // the event store from which to start reading events (exclusive) 277 | func WithOffset(offset int) SubAllOpt { 278 | return func(cfg SubAllConfig) SubAllConfig { 279 | cfg.offset = offset 280 | 281 | return cfg 282 | } 283 | } 284 | 285 | // WithBatchSize is a subscription/read all option that specifies the read 286 | // batch size (limit) when reading events from the event store 287 | func WithBatchSize(size int) SubAllOpt { 288 | return func(cfg SubAllConfig) SubAllConfig { 289 | cfg.batchSize = size 290 | 291 | return cfg 292 | } 293 | } 294 | 295 | // WithPollInterval is a subscription/read all option that specifies the poolling 296 | // interval of the underlying sqlite database 297 | func WithPollInterval(d time.Duration) SubAllOpt { 298 | return func(cfg SubAllConfig) SubAllConfig { 299 | cfg.pollInterval = d 300 | 301 | return cfg 302 | } 303 | } 304 | 305 | // Subscription represents ReadAll subscription that is used for streaming 306 | // incoming events 307 | type Subscription struct { 308 | // Err chan will produce any errors that might occur while reading events 309 | // If Err produces io.EOF error, that indicates that we have caught up 310 | // with the event store and that there are no more events to read after which 311 | // the subscription itself will continue polling the event store for new events 312 | // each time we empty the Err channel. This means that reading from Err (in 313 | // case of io.EOF) can be strategically used in order to achieve backpressure 314 | Err chan error 315 | EventData chan StoredEvent 316 | 317 | close chan struct{} 318 | } 319 | 320 | // Close closes the subscription and halts the polling of sqldb 321 | func (s Subscription) Close() { 322 | if s.close == nil { 323 | return 324 | } 325 | 326 | s.close <- struct{}{} 327 | } 328 | 329 | // ReadAll will read all events from the event store by internally creating a 330 | // a subscription and depleting it until io.EOF is encountered 331 | // WARNING: Use with caution as this method will read the entire event store 332 | // in a blocking fashion (probably best used in combination with offset option) 333 | func (es *EventStore) ReadAll(ctx context.Context, opts ...SubAllOpt) ([]StoredEvent, error) { 334 | sub, err := es.SubscribeAll(ctx, opts...) 335 | if err != nil { 336 | return nil, err 337 | } 338 | 339 | defer sub.Close() 340 | 341 | var events []StoredEvent 342 | 343 | for { 344 | select { 345 | case data := <-sub.EventData: 346 | events = append(events, data) 347 | 348 | case err := <-sub.Err: 349 | if errors.Is(err, io.EOF) { 350 | return events, nil 351 | } 352 | 353 | return nil, err 354 | } 355 | } 356 | } 357 | 358 | // SubscribeAll will create a subscription which can be used to stream all events in an 359 | // orderly fashion. This mechanism should probably be mostly useful for building projections 360 | func (es *EventStore) SubscribeAll(ctx context.Context, opts ...SubAllOpt) (Subscription, error) { 361 | cfg := SubAllConfig{ 362 | offset: 0, 363 | batchSize: 100, 364 | pollInterval: 100 * time.Millisecond, 365 | } 366 | 367 | for _, opt := range opts { 368 | cfg = opt(cfg) 369 | } 370 | 371 | if cfg.batchSize < 1 { 372 | return Subscription{}, fmt.Errorf("batch size should be at least 1") 373 | } 374 | 375 | sub := Subscription{ 376 | Err: make(chan error, 1), 377 | EventData: make(chan StoredEvent, cfg.batchSize), 378 | close: make(chan struct{}, 1), 379 | } 380 | 381 | go func() { 382 | var done error 383 | 384 | for { 385 | select { 386 | case <-sub.close: 387 | sub.Err <- ErrSubscriptionClosedByClient 388 | 389 | return 390 | case <-ctx.Done(): 391 | sub.Err <- ctx.Err() 392 | 393 | return 394 | case <-time.After(cfg.pollInterval): 395 | // Make sure client reads all buffered events 396 | if done != nil { 397 | if len(sub.EventData) != 0 { 398 | break 399 | } 400 | 401 | sub.Err <- done 402 | 403 | return 404 | } 405 | 406 | var evts []gormEvent 407 | 408 | if err := es.DB. 409 | Where("sequence > ?", cfg.offset). 410 | Order("sequence asc"). 411 | Limit(cfg.batchSize). 412 | Find(&evts).Error; err != nil { 413 | done = err 414 | 415 | break 416 | } 417 | 418 | if len(evts) == 0 { 419 | sub.Err <- io.EOF 420 | 421 | break 422 | } 423 | 424 | cfg.offset = cfg.offset + len(evts) 425 | 426 | decoded, err := es.decodeEvents(evts) 427 | if err != nil { 428 | done = err 429 | 430 | break 431 | } 432 | 433 | for _, evt := range decoded { 434 | sub.EventData <- evt 435 | } 436 | } 437 | } 438 | }() 439 | 440 | return sub, nil 441 | } 442 | 443 | // ReadStream will read all events associated with provided stream 444 | // If there are no events stored for a given stream ErrStreamNotFound will be returned 445 | func (es *EventStore) ReadStream(ctx context.Context, stream string) ([]StoredEvent, error) { 446 | var events []gormEvent 447 | 448 | if len(stream) == 0 { 449 | return nil, fmt.Errorf("stream name must be provided") 450 | } 451 | 452 | if err := es.DB. 453 | WithContext(ctx). 454 | Where("stream_id = ?", stream). 455 | Order("sequence asc"). 456 | Find(&events).Error; err != nil { 457 | 458 | return nil, err 459 | } 460 | 461 | if len(events) == 0 { 462 | return nil, ErrStreamNotFound 463 | } 464 | 465 | return es.decodeEvents(events) 466 | } 467 | 468 | func (es *EventStore) decodeEvents(events []gormEvent) ([]StoredEvent, error) { 469 | out := make([]StoredEvent, len(events)) 470 | 471 | for i, evt := range events { 472 | data, err := es.enc.Decode(&EncodedEvt{ 473 | Data: evt.Data, 474 | Type: evt.Type, 475 | }) 476 | if err != nil { 477 | return nil, err 478 | } 479 | 480 | var meta map[string]string 481 | 482 | if evt.Meta != nil { 483 | err = json.Unmarshal([]byte(*evt.Meta), &meta) 484 | if err != nil { 485 | return nil, err 486 | } 487 | } 488 | 489 | out[i] = StoredEvent{ 490 | Event: data, 491 | Meta: meta, 492 | ID: evt.ID, 493 | Sequence: evt.Sequence, 494 | Type: evt.Type, 495 | CausationEventID: evt.CausationEventID, 496 | CorrelationEventID: evt.CorrelationEventID, 497 | StreamID: evt.StreamID, 498 | StreamVersion: evt.StreamVersion, 499 | OccurredOn: evt.OccurredOn, 500 | } 501 | } 502 | 503 | return out, nil 504 | } 505 | -------------------------------------------------------------------------------- /eventstore_test.go: -------------------------------------------------------------------------------- 1 | package eventstore_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "github.com/aneshas/tx/v2" 9 | "github.com/aneshas/tx/v2/gormtx" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/testcontainers/testcontainers-go" 12 | "github.com/testcontainers/testcontainers-go/modules/postgres" 13 | "github.com/testcontainers/testcontainers-go/wait" 14 | "io" 15 | "log" 16 | "reflect" 17 | "testing" 18 | "time" 19 | 20 | "github.com/aneshas/eventstore" 21 | ) 22 | 23 | var withPG = flag.Bool("withpg", false, "run tests with postgres") 24 | 25 | type SomeEvent struct { 26 | UserID string 27 | } 28 | 29 | func TestShouldReadAppendedEvents(t *testing.T) { 30 | es, cleanup := eventStore(t) 31 | 32 | defer cleanup() 33 | 34 | evts := []any{ 35 | SomeEvent{ 36 | UserID: "user-1", 37 | }, 38 | SomeEvent{ 39 | UserID: "user-2", 40 | }, 41 | SomeEvent{ 42 | UserID: "user-2", 43 | }, 44 | } 45 | 46 | ctx := context.Background() 47 | stream := "some-stream" 48 | meta := map[string]string{ 49 | "ip": "127.0.0.1", 50 | } 51 | 52 | err := es.AppendStream( 53 | ctx, stream, eventstore.InitialStreamVersion, toEventToStore(evts...), 54 | ) 55 | 56 | if err != nil { 57 | t.Fatalf("error: %v", err) 58 | } 59 | 60 | got, err := es.ReadStream(ctx, stream) 61 | if err != nil { 62 | t.Fatalf("error: %v", err) 63 | } 64 | 65 | for i, evt := range got { 66 | if !reflect.DeepEqual(evt.Event, evts[i]) || 67 | !reflect.DeepEqual(evt.Meta, meta) || 68 | *evt.CorrelationEventID != "123" || 69 | *evt.CausationEventID != "456" || 70 | evt.Type != "SomeEvent" { 71 | 72 | t.Fatal("events not read") 73 | } 74 | } 75 | } 76 | 77 | func TestShouldWriteToDifferentStreamsWithTransaction(t *testing.T) { 78 | es, cleanup := eventStore(t) 79 | 80 | defer cleanup() 81 | 82 | evts := []interface{}{ 83 | SomeEvent{ 84 | UserID: "user-1", 85 | }, 86 | SomeEvent{ 87 | UserID: "user-2", 88 | }, 89 | SomeEvent{ 90 | UserID: "user-2", 91 | }, 92 | } 93 | 94 | ctx := context.Background() 95 | streamOne := "some-stream" 96 | streamTwo := "another-stream" 97 | 98 | transactor := tx.New(gormtx.NewDB(es.DB)) 99 | 100 | err := transactor.WithTransaction(ctx, func(ctx context.Context) error { 101 | err := es.AppendStream( 102 | ctx, streamOne, eventstore.InitialStreamVersion, toEventToStore(evts...), 103 | ) 104 | if err != nil { 105 | t.Fatalf("error: %v", err) 106 | } 107 | 108 | err = es.AppendStream( 109 | ctx, streamTwo, eventstore.InitialStreamVersion, toEventToStore(evts...), 110 | ) 111 | if err != nil { 112 | t.Fatalf("error: %v", err) 113 | } 114 | 115 | return nil 116 | }) 117 | 118 | assert.NoError(t, err) 119 | } 120 | 121 | func TestShouldAppendToExistingStream(t *testing.T) { 122 | es, cleanup := eventStore(t) 123 | 124 | defer cleanup() 125 | 126 | evts := []interface{}{ 127 | SomeEvent{ 128 | UserID: "user-1", 129 | }, 130 | SomeEvent{ 131 | UserID: "user-2", 132 | }, 133 | SomeEvent{ 134 | UserID: "user-2", 135 | }, 136 | } 137 | 138 | ctx := context.Background() 139 | stream := "some-stream" 140 | 141 | err := es.AppendStream( 142 | ctx, stream, eventstore.InitialStreamVersion, toEventToStore(evts...), 143 | ) 144 | if err != nil { 145 | t.Fatalf("error: %v", err) 146 | } 147 | 148 | err = es.AppendStream( 149 | ctx, stream, 3, toEventToStore(evts...), 150 | ) 151 | 152 | if err != nil { 153 | t.Fatalf("error: %v", err) 154 | } 155 | } 156 | 157 | func TestOptimisticConcurrencyCheckIsPerformed(t *testing.T) { 158 | es, cleanup := eventStore(t) 159 | 160 | defer cleanup() 161 | 162 | evts := []interface{}{ 163 | SomeEvent{ 164 | UserID: "user-1", 165 | }, 166 | } 167 | 168 | ctx := context.Background() 169 | stream := "some-stream" 170 | 171 | err := es.AppendStream( 172 | ctx, stream, eventstore.InitialStreamVersion, toEventToStore(evts...), 173 | ) 174 | if err != nil { 175 | t.Fatalf("error: %v", err) 176 | } 177 | 178 | err = es.AppendStream( 179 | ctx, stream, eventstore.InitialStreamVersion, toEventToStore(evts...), 180 | ) 181 | 182 | if !errors.Is(err, eventstore.ErrConcurrencyCheckFailed) { 183 | t.Fatalf("should have performed optimistic concurrency check") 184 | } 185 | } 186 | 187 | func TestReadStreamWrapsNotFoundError(t *testing.T) { 188 | es, cleanup := eventStore(t) 189 | 190 | defer cleanup() 191 | 192 | _, err := es.ReadStream(context.Background(), "foo-stream") 193 | if !errors.Is(err, eventstore.ErrStreamNotFound) { 194 | t.Fatal("should return explicit error if stream doesn't exist") 195 | } 196 | } 197 | 198 | func TestSubscribeAllWithOffsetCatchesUpToNewEvents(t *testing.T) { 199 | es, cleanup := eventStore(t) 200 | 201 | defer cleanup() 202 | 203 | evts := []interface{}{ 204 | SomeEvent{ 205 | UserID: "user-1", 206 | }, 207 | SomeEvent{ 208 | UserID: "user-2", 209 | }, 210 | SomeEvent{ 211 | UserID: "user-2", 212 | }, 213 | } 214 | 215 | ctx := context.Background() 216 | 217 | err := es.AppendStream(ctx, "stream-one", eventstore.InitialStreamVersion, toEventToStore(evts...)) 218 | if err != nil { 219 | t.Fatal(err) 220 | } 221 | 222 | sub, err := es.SubscribeAll( 223 | ctx, 224 | eventstore.WithOffset(1), 225 | eventstore.WithPollInterval(50*time.Millisecond), 226 | ) 227 | if err != nil { 228 | t.Fatal(err) 229 | } 230 | 231 | defer sub.Close() 232 | 233 | got := readAllSub(t, sub, 2) 234 | 235 | if len(got) != 2 { 236 | t.Fatalf("should have read 2 events. actual: %d", len(got)) 237 | } 238 | 239 | evtsTwo := []interface{}{ 240 | SomeEvent{ 241 | UserID: "user-1", 242 | }, 243 | SomeEvent{ 244 | UserID: "user-2", 245 | }, 246 | SomeEvent{ 247 | UserID: "user-2", 248 | }, 249 | SomeEvent{ 250 | UserID: "user-2", 251 | }, 252 | } 253 | 254 | err = es.AppendStream(ctx, "stream-two", eventstore.InitialStreamVersion, toEventToStore(evtsTwo...)) 255 | if err != nil { 256 | t.Fatal(err) 257 | } 258 | 259 | got = readAllSub(t, sub, 4) 260 | 261 | if len(got) != 4 { 262 | t.Fatalf("should have read 4 events. actual: %d", len(got)) 263 | } 264 | } 265 | 266 | func readAllSub(t *testing.T, sub eventstore.Subscription, expect int) []eventstore.StoredEvent { 267 | var got []eventstore.StoredEvent 268 | 269 | outer: 270 | for { 271 | select { 272 | case data := <-sub.EventData: 273 | got = append(got, data) 274 | 275 | case err := <-sub.Err: 276 | if err != nil { 277 | if errors.Is(err, io.EOF) { 278 | if len(got) < expect { 279 | break 280 | } 281 | 282 | break outer 283 | } 284 | 285 | t.Fatal(err) 286 | } 287 | } 288 | } 289 | 290 | return got 291 | } 292 | 293 | func TestReadAllShouldReadAllEvents(t *testing.T) { 294 | es, cleanup := eventStore(t) 295 | 296 | defer cleanup() 297 | 298 | evts := []interface{}{ 299 | SomeEvent{ 300 | UserID: "user-1", 301 | }, 302 | SomeEvent{ 303 | UserID: "user-2", 304 | }, 305 | SomeEvent{ 306 | UserID: "user-3", 307 | }, 308 | } 309 | 310 | ctx := context.Background() 311 | 312 | err := es.AppendStream(ctx, "stream-one", eventstore.InitialStreamVersion, toEventToStore(evts...)) 313 | if err != nil { 314 | t.Fatal(err) 315 | } 316 | 317 | data, err := es.ReadAll(ctx) 318 | if err != nil { 319 | t.Fatal(err) 320 | } 321 | 322 | got := []interface{}{ 323 | data[0].Event.(SomeEvent), 324 | data[1].Event.(SomeEvent), 325 | data[2].Event.(SomeEvent), 326 | } 327 | 328 | if !reflect.DeepEqual(evts, got) { 329 | t.Fatal("all events should have been read") 330 | } 331 | } 332 | 333 | func TestSubscribeAllCancelsSubscriptionOnContextCancel(t *testing.T) { 334 | es, cleanup := eventStore(t) 335 | 336 | defer cleanup() 337 | 338 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 339 | 340 | _ = cancel 341 | 342 | sub, _ := es.SubscribeAll(ctx) 343 | 344 | timeout := time.After(2 * time.Second) 345 | 346 | for { 347 | select { 348 | case <-timeout: 349 | t.Fatal("subscription should have been closed") 350 | case err := <-sub.Err: 351 | if errors.Is(err, io.EOF) { 352 | break 353 | } 354 | 355 | return 356 | } 357 | } 358 | } 359 | 360 | func TestSubscribeAllCancelsSubscriptionWithClose(t *testing.T) { 361 | es, cleanup := eventStore(t) 362 | 363 | defer cleanup() 364 | 365 | sub, _ := es.SubscribeAll(context.Background()) 366 | 367 | go func() { 368 | time.Sleep(time.Second) 369 | 370 | sub.Close() 371 | }() 372 | 373 | timeout := time.After(2 * time.Second) 374 | 375 | for { 376 | select { 377 | case <-timeout: 378 | t.Fatal("subscription should have been closed") 379 | case err := <-sub.Err: 380 | if errors.Is(err, io.EOF) { 381 | break 382 | } 383 | 384 | if !errors.Is(err, eventstore.ErrSubscriptionClosedByClient) { 385 | t.Fatal("incorrect subscription cancel error") 386 | } 387 | 388 | return 389 | } 390 | } 391 | } 392 | 393 | type enc struct { 394 | encode func(interface{}) (*eventstore.EncodedEvt, error) 395 | decode func(*eventstore.EncodedEvt) (interface{}, error) 396 | } 397 | 398 | func (e enc) Encode(evt interface{}) (*eventstore.EncodedEvt, error) { 399 | return e.encode(evt) 400 | } 401 | 402 | func (e enc) Decode(evt *eventstore.EncodedEvt) (interface{}, error) { 403 | return e.decode(evt) 404 | } 405 | 406 | func TestEncoderEncodeErrorsPropagated(t *testing.T) { 407 | var anErr = fmt.Errorf("an error occurred") 408 | 409 | e := enc{ 410 | encode: func(i interface{}) (*eventstore.EncodedEvt, error) { return nil, anErr }, 411 | } 412 | 413 | es, cleanup := eventStoreWithDec(t, e) 414 | 415 | defer cleanup() 416 | 417 | err := es.AppendStream( 418 | context.Background(), 419 | "stream", 420 | eventstore.InitialStreamVersion, 421 | []eventstore.EventToStore{ 422 | { 423 | Event: SomeEvent{ 424 | UserID: "123", 425 | }, 426 | }, 427 | }, 428 | ) 429 | 430 | if !errors.Is(err, anErr) { 431 | t.Fatal("error should have been propagated") 432 | } 433 | } 434 | 435 | func TestEncoderDecodeErrorsPropagated(t *testing.T) { 436 | var anErr = fmt.Errorf("an error occurred") 437 | 438 | e := enc{ 439 | encode: func(i interface{}) (*eventstore.EncodedEvt, error) { 440 | return &eventstore.EncodedEvt{ 441 | Data: "malformed-json", 442 | Type: "foo", 443 | }, nil 444 | }, 445 | decode: func(ee *eventstore.EncodedEvt) (interface{}, error) { 446 | return nil, anErr 447 | }, 448 | } 449 | 450 | es, cleanup := eventStoreWithDec(t, e) 451 | 452 | defer cleanup() 453 | 454 | err := es.AppendStream( 455 | context.Background(), 456 | "stream", 457 | eventstore.InitialStreamVersion, 458 | []eventstore.EventToStore{ 459 | { 460 | Event: SomeEvent{ 461 | UserID: "123", 462 | }, 463 | }, 464 | }, 465 | ) 466 | if err != nil { 467 | t.Fatal(err) 468 | } 469 | 470 | _, err = es.ReadStream(context.Background(), "stream") 471 | 472 | if !errors.Is(err, anErr) { 473 | t.Fatal("error should have been propagated") 474 | } 475 | } 476 | 477 | func TestEncoderDecodeErrorsPropagatedOnSubscribeAll(t *testing.T) { 478 | var anErr = fmt.Errorf("an error occurred") 479 | 480 | e := enc{ 481 | encode: func(i interface{}) (*eventstore.EncodedEvt, error) { 482 | return &eventstore.EncodedEvt{ 483 | Data: "malformed-json", 484 | Type: "foo", 485 | }, nil 486 | }, 487 | decode: func(ee *eventstore.EncodedEvt) (interface{}, error) { 488 | return nil, anErr 489 | }, 490 | } 491 | 492 | es, cleanup := eventStoreWithDec(t, e) 493 | 494 | defer cleanup() 495 | 496 | err := es.AppendStream( 497 | context.Background(), 498 | "stream", 499 | eventstore.InitialStreamVersion, 500 | []eventstore.EventToStore{ 501 | { 502 | Event: SomeEvent{ 503 | UserID: "123", 504 | }, 505 | }, 506 | }, 507 | ) 508 | if err != nil { 509 | t.Fatal(err) 510 | } 511 | 512 | sub, err := es.SubscribeAll(context.Background()) 513 | if err != nil { 514 | t.Fatal(err) 515 | } 516 | 517 | defer sub.Close() 518 | 519 | err = <-sub.Err 520 | 521 | if !errors.Is(err, anErr) { 522 | t.Fatal("error should have been propagated") 523 | } 524 | } 525 | 526 | func TestNewEncoderMustBeProvided(t *testing.T) { 527 | _, err := eventstore.New(nil) 528 | if err == nil { 529 | t.Fatal("encoder must be provided") 530 | } 531 | } 532 | 533 | func TestNewDBMustBeProvided(t *testing.T) { 534 | _, err := eventstore.New(eventstore.NewJSONEncoder()) 535 | if err == nil { 536 | t.Fatal("DB con must be provided") 537 | } 538 | } 539 | 540 | func TestAppendStreamValidation(t *testing.T) { 541 | es := eventstore.EventStore{} 542 | 543 | cases := []struct { 544 | stream string 545 | ver int 546 | evts []interface{} 547 | }{ 548 | { 549 | stream: "", 550 | ver: 0, 551 | evts: []interface{}{ 552 | SomeEvent{ 553 | UserID: "user-123", 554 | }, 555 | }, 556 | }, 557 | { 558 | stream: "s", 559 | ver: -1, 560 | evts: []interface{}{ 561 | SomeEvent{ 562 | UserID: "user-123", 563 | }, 564 | }, 565 | }, 566 | } 567 | 568 | for i, tc := range cases { 569 | t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) { 570 | err := es.AppendStream(context.Background(), tc.stream, tc.ver, toEventToStore(tc.evts)) 571 | if err == nil { 572 | t.Fatal("validation error should have happened") 573 | } 574 | }) 575 | } 576 | } 577 | 578 | func TestSubscribeAllMinimumBatchSize(t *testing.T) { 579 | es := eventstore.EventStore{} 580 | 581 | _, err := es.SubscribeAll(context.Background(), eventstore.WithBatchSize(-1)) 582 | if err == nil { 583 | t.Fatal("minimum batch size should have been validated") 584 | } 585 | } 586 | 587 | func TestReadAllMinimumBatchSize(t *testing.T) { 588 | es := eventstore.EventStore{} 589 | 590 | _, err := es.ReadAll(context.Background(), eventstore.WithBatchSize(-1)) 591 | if err == nil { 592 | t.Fatal("minimum batch size should have been validated") 593 | } 594 | } 595 | 596 | func TestReadStreamValidation(t *testing.T) { 597 | es := eventstore.EventStore{} 598 | 599 | _, err := es.ReadStream(context.Background(), "") 600 | if err == nil { 601 | t.Fatal("stream name should be provided") 602 | } 603 | } 604 | 605 | func eventStore(t *testing.T) (*eventstore.EventStore, func()) { 606 | return eventStoreWithDec(t, eventstore.NewJSONEncoder(SomeEvent{})) 607 | } 608 | 609 | func eventStoreWithDec(t *testing.T, enc eventstore.Encoder) (*eventstore.EventStore, func()) { 610 | t.Helper() 611 | 612 | if *withPG { 613 | ctx := context.Background() 614 | 615 | dbName := "event-store" 616 | dbUser := "user" 617 | dbPassword := "password" 618 | 619 | postgresContainer, err := postgres.Run( 620 | ctx, 621 | "docker.io/postgres:16-alpine", 622 | postgres.WithDatabase(dbName), 623 | postgres.WithUsername(dbUser), 624 | postgres.WithPassword(dbPassword), 625 | testcontainers.WithWaitStrategy( 626 | wait.ForLog("database system is ready to accept connections"). 627 | WithOccurrence(2). 628 | WithStartupTimeout(5*time.Second)), 629 | ) 630 | if err != nil { 631 | t.Fatal(err) 632 | } 633 | 634 | dsn, err := postgresContainer.ConnectionString(ctx) 635 | if err != nil { 636 | t.Fatal(err) 637 | } 638 | 639 | es, err := eventstore.New(enc, eventstore.WithPostgresDB(dsn)) 640 | if err != nil { 641 | t.Fatalf("error creating es: %v", err) 642 | } 643 | 644 | return es, func() { 645 | if err := postgresContainer.Stop(ctx, nil); err != nil { 646 | // if err := testcontainers.TerminateContainer(postgresContainer); err != nil { 647 | log.Fatalf("failed to terminate container: %s", err) 648 | } 649 | } 650 | } 651 | 652 | es, err := eventstore.New(enc, eventstore.WithSQLiteDB("file::memory:?cache=shared")) 653 | if err != nil { 654 | t.Fatalf("error creating es: %v", err) 655 | } 656 | 657 | return es, func() { 658 | err := es.Close() 659 | if err != nil { 660 | t.Fatal(err) 661 | } 662 | } 663 | } 664 | 665 | func toEventToStore(events ...any) []eventstore.EventToStore { 666 | var evts []eventstore.EventToStore 667 | 668 | for _, evt := range events { 669 | evts = append(evts, eventstore.EventToStore{ 670 | Event: evt, 671 | Meta: map[string]string{ 672 | "ip": "127.0.0.1", 673 | }, 674 | CorrelationEventID: "123", 675 | CausationEventID: "456", 676 | }) 677 | } 678 | 679 | return evts 680 | } 681 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # EventStore Example 2 | 3 | This example shows a simplistic but typical event-sourcing use case scenario. 4 | 5 | It contains a single "aggregate" (Account) that produces a set of events, with three projections - two of which make use of a built-in projector and flush after projection, 6 | and one using built-in Ambar wrapper projection library 7 | 8 | ## How to run 9 | 10 | Run both `cmd/api/main.go` and `cmd/projections/main.go` in any order from the same directory (so they use the same sqlite db). This will start a simple http api on `localhost:8080` and run projection binary which will subscribe to the event store and wait for incoming events in order to process them. 11 | 12 | The api will run on localhost:8080 and following endpoints are available: 13 | - `/accounts/open` 14 | - `/accounts/:id/deposit/:amount` 15 | - `/accounts/:id/withdraw/:amount` 16 | 17 | Monitor the output of the projections binary in order to see the effects of console projection. In addition to that, the second projection should create a json file on disk containing created accounts (named `accounts.json`). 18 | 19 | ## Ambar 20 | If you want to try it with Ambar - you will need to set up your db, host the `api` and `ambar_projections` binaries 21 | somewhere (or run them locally and tunnel via ngrok for example) and set up Ambar resources yourself and run the api binary with `-pg` option. (will provide a local docker-compose configuration) 22 | 23 | ### Configuring your postgres db for Ambar: 24 | 25 | ```sql 26 | CREATE USER replication REPLICATION LOGIN PASSWORD 'repl-pass'; 27 | 28 | GRANT CONNECT ON DATABASE "your-db-name" TO replication; 29 | 30 | GRANT SELECT ON TABLE event TO replication; 31 | 32 | CREATE PUBLICATION event_publication FOR TABLE event; 33 | ``` 34 | 35 | Ambar data-source configuration example for the above configuration: 36 | ```json 37 | { 38 | "dataSourceType": "postgres", 39 | "description": "Eventstore", 40 | "dataSourceConfig": { 41 | "hostname": "your-db-host", 42 | "hostPort": "5432", 43 | "databaseName": "your-db-name", 44 | "tableName": "event", 45 | "publicationName": "event_publication", 46 | "columns": "id,sequence,type,data,meta,causation_event_id,correlation_event_id,stream_id,stream_version,occurred_on", 47 | "username": "replication", 48 | "password": "repl-pass", 49 | "serialColumn": "sequence", 50 | "partitioningColumn": "stream_id" 51 | } 52 | } 53 | ``` 54 | 55 | After everything has been wired up you can run/deploy `ambar_projections` binary which exposes a single projection endpoint 56 | (`/projections/accounts/v1`) to be used with Ambar data destination. 57 | 58 | Set your data-destination username and password to `user` and `pass` respectively 59 | -------------------------------------------------------------------------------- /example/account/account.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aneshas/eventstore/aggregate" 6 | ) 7 | 8 | // New opens a new Account 9 | func New(id ID, holder string) (*Account, error) { 10 | var acc Account 11 | 12 | // We always need to call Rehydrate on a fresh instance in order to initialize the aggregate 13 | // so the events can be applied to it properly 14 | // (aggregate.Store ByID will do this automatically for us when we load an aggregate from the event store) 15 | acc.Rehydrate(&acc) 16 | 17 | acc.Apply( 18 | NewAccountOpened{ 19 | AccountID: id.String(), 20 | Holder: holder, 21 | }, 22 | ) 23 | 24 | return &acc, nil 25 | } 26 | 27 | // Account represents an account aggregate 28 | type Account struct { 29 | aggregate.Root[ID] 30 | 31 | Balance int 32 | } 33 | 34 | // Deposit money 35 | func (a *Account) Deposit(amount int) { 36 | a.Apply( 37 | DepositMade{ 38 | AccountID: a.StringID(), 39 | Amount: amount, 40 | }, 41 | ) 42 | } 43 | 44 | // Withdraw money 45 | func (a *Account) Withdraw(amount int) error { 46 | if a.Balance < amount { 47 | return fmt.Errorf("insufficient funds") 48 | } 49 | 50 | a.Apply( 51 | WithdrawalMade{ 52 | AccountID: a.StringID(), 53 | Amount: amount, 54 | }, 55 | ) 56 | 57 | return nil 58 | } 59 | 60 | // OnNewAccountOpened handler 61 | func (a *Account) OnNewAccountOpened(evt NewAccountOpened) { 62 | a.ID = ParseID(evt.AccountID) 63 | } 64 | 65 | // OnDepositMade handler 66 | func (a *Account) OnDepositMade(evt DepositMade) { 67 | a.Balance += evt.Amount 68 | } 69 | 70 | // OnWithdrawalMade handler 71 | func (a *Account) OnWithdrawalMade(evt WithdrawalMade, _ aggregate.Event) { 72 | a.Balance -= evt.Amount 73 | } 74 | -------------------------------------------------------------------------------- /example/account/events.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | // Events is a list of all account domain event instances 4 | var Events = []any{ 5 | NewAccountOpened{}, 6 | DepositMade{}, 7 | WithdrawalMade{}, 8 | } 9 | 10 | // NewAccountOpened domain event indicates that new 11 | // account has been opened 12 | type NewAccountOpened struct { 13 | AccountID string 14 | Holder string 15 | } 16 | 17 | // DepositMade domain event indicates that deposit has been made 18 | type DepositMade struct { 19 | AccountID string 20 | Amount int 21 | } 22 | 23 | // WithdrawalMade domain event indicates that withdrawal has been made 24 | type WithdrawalMade struct { 25 | AccountID string 26 | Amount int 27 | } 28 | -------------------------------------------------------------------------------- /example/account/id.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import "github.com/google/uuid" 4 | 5 | // NewID generates a new account ID 6 | func NewID() ID { 7 | return ID{uuid.New()} 8 | } 9 | 10 | // ID represents an account ID 11 | type ID struct { 12 | uuid.UUID 13 | } 14 | 15 | // ParseID parses account ID from string 16 | func ParseID(id string) ID { 17 | return ID{uuid.MustParse(id)} 18 | } 19 | -------------------------------------------------------------------------------- /example/cmd/ambar_projections/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aneshas/eventstore" 6 | "github.com/aneshas/eventstore-example/account" 7 | "github.com/aneshas/eventstore/ambar" 8 | "github.com/aneshas/eventstore/ambar/echoambar" 9 | "github.com/labstack/echo/v4" 10 | "github.com/labstack/echo/v4/middleware" 11 | "log" 12 | "net/http" 13 | ) 14 | 15 | func main() { 16 | e := echo.New() 17 | 18 | e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { 19 | if username == "user" && password == "pass" { 20 | return true, nil 21 | } 22 | 23 | return false, nil 24 | })) 25 | 26 | hf := echoambar.Wrap( 27 | ambar.New(eventstore.NewJSONEncoder(eventSubscriptions...)), 28 | ) 29 | 30 | e.POST("/projections/accounts/v1", hf(NewConsoleOutputProjection())) 31 | 32 | log.Fatal(e.Start(":8181")) 33 | } 34 | 35 | var eventSubscriptions = []any{ 36 | account.NewAccountOpened{}, 37 | account.DepositMade{}, 38 | account.WithdrawalMade{}, 39 | } 40 | 41 | // NewConsoleOutputProjection constructs an example projection that outputs 42 | // new accounts to the console. It might as well be to any kind of 43 | // database, disk, memory etc... 44 | func NewConsoleOutputProjection() ambar.Projection { 45 | return func(_ *http.Request, event eventstore.StoredEvent) error { 46 | switch event.Event.(type) { 47 | case account.NewAccountOpened: 48 | evt := event.Event.(account.NewAccountOpened) 49 | fmt.Printf("Account: #%s | Holder: <%s>\n", evt.AccountID, evt.Holder) 50 | 51 | case account.DepositMade: 52 | evt := event.Event.(account.DepositMade) 53 | fmt.Printf("Deposited the amount of %d EUR\n", evt.Amount) 54 | 55 | default: 56 | fmt.Println("not interested in this event") 57 | } 58 | 59 | return nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /example/cmd/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "github.com/aneshas/eventstore" 7 | "github.com/aneshas/eventstore-example/account" 8 | "github.com/aneshas/eventstore/aggregate" 9 | "github.com/labstack/echo/v4" 10 | "log" 11 | "net/http" 12 | "os" 13 | "strconv" 14 | ) 15 | 16 | var pg = flag.Bool("pg", false, "Run with postgres db (set env DSN to pg connection string)") 17 | 18 | func main() { 19 | flag.Parse() 20 | 21 | db := eventstore.WithSQLiteDB("example.db") 22 | 23 | if *pg { 24 | db = eventstore.WithPostgresDB(os.Getenv("DSN")) 25 | } 26 | 27 | eventStore, err := eventstore.New( 28 | eventstore.NewJSONEncoder(account.Events...), 29 | db, 30 | ) 31 | checkErr(err) 32 | 33 | defer func() { 34 | _ = eventStore.Close() 35 | }() 36 | 37 | e := echo.New() 38 | 39 | e.Use(mw()) 40 | 41 | e.GET("/accounts/open", NewOpenAccountHandlerFunc(eventStore)) 42 | e.GET("/accounts/:id/deposit/:amount", NewDepositToAccountHandlerFunc(eventStore)) 43 | e.GET("/accounts/:id/withdraw/:amount", NewWithdrawFromAccountHandlerFunc(eventStore)) 44 | 45 | e.HTTPErrorHandler = func(err error, c echo.Context) { 46 | if errors.Is(err, aggregate.ErrAggregateNotFound) { 47 | _ = c.String(http.StatusNotFound, "Account not found") 48 | } 49 | } 50 | 51 | log.Fatal(e.Start(":8080")) 52 | } 53 | 54 | func mw() echo.MiddlewareFunc { 55 | return func(next echo.HandlerFunc) echo.HandlerFunc { 56 | return func(c echo.Context) error { 57 | ctx := aggregate.CtxWithMeta(c.Request().Context(), map[string]string{ 58 | "ip": c.Request().RemoteAddr, 59 | "app": "example-app", 60 | }) 61 | 62 | ctx = aggregate.CtxWithCausationID(ctx, "some-causation-event-id") 63 | ctx = aggregate.CtxWithCorrelationID(ctx, "some-correlation-event-id") 64 | 65 | c.SetRequest(c.Request().WithContext(ctx)) 66 | 67 | return next(c) 68 | } 69 | } 70 | } 71 | 72 | type newAccountResp struct { 73 | AccountID string `json:"account_id"` 74 | } 75 | 76 | // NewOpenAccountHandlerFunc creates new account opening endpoint example 77 | func NewOpenAccountHandlerFunc(eventStore *eventstore.EventStore) echo.HandlerFunc { 78 | store := aggregate.NewStore[*account.Account](eventStore) 79 | 80 | return func(c echo.Context) error { 81 | acc, err := account.New(account.NewID(), "John Doe") 82 | if err != nil { 83 | return err 84 | } 85 | 86 | err = store.Save(c.Request().Context(), acc) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return c.JSON(http.StatusCreated, newAccountResp{AccountID: acc.StringID()}) 92 | } 93 | } 94 | 95 | type depositResp struct { 96 | AccountID string `json:"account_id"` 97 | NewBalance int `json:"new_balance"` 98 | } 99 | 100 | // NewDepositToAccountHandlerFunc creates new deposit to account endpoint example 101 | func NewDepositToAccountHandlerFunc(eventStore *eventstore.EventStore) echo.HandlerFunc { 102 | store := aggregate.NewStore[*account.Account](eventStore) 103 | 104 | return func(c echo.Context) error { 105 | var ( 106 | acc account.Account 107 | ctx = c.Request().Context() 108 | ) 109 | 110 | err := store.ByID(ctx, c.Param("id"), &acc) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | amount, _ := strconv.Atoi(c.Param("amount")) 116 | 117 | acc.Deposit(amount) 118 | 119 | err = store.Save(ctx, &acc) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | return c.JSON( 125 | http.StatusOK, 126 | depositResp{ 127 | AccountID: acc.StringID(), 128 | NewBalance: acc.Balance, 129 | }, 130 | ) 131 | } 132 | } 133 | 134 | type withdrawResp struct { 135 | AccountID string `json:"account_id"` 136 | NewBalance int `json:"new_balance"` 137 | } 138 | 139 | // NewWithdrawFromAccountHandlerFunc creates new withdraw from account endpoint example 140 | func NewWithdrawFromAccountHandlerFunc(eventStore *eventstore.EventStore) echo.HandlerFunc { 141 | store := aggregate.NewStore[*account.Account](eventStore) 142 | 143 | return func(c echo.Context) error { 144 | var ( 145 | acc account.Account 146 | ctx = c.Request().Context() 147 | ) 148 | 149 | err := store.ByID(ctx, c.Param("id"), &acc) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | amount, _ := strconv.Atoi(c.Param("amount")) 155 | 156 | err = acc.Withdraw(amount) 157 | if err != nil { 158 | return c.String(http.StatusBadRequest, err.Error()) 159 | } 160 | 161 | err = store.Save(ctx, &acc) 162 | if err != nil { 163 | return err 164 | } 165 | 166 | return c.JSON( 167 | http.StatusOK, 168 | withdrawResp{ 169 | AccountID: acc.StringID(), 170 | NewBalance: acc.Balance, 171 | }, 172 | ) 173 | } 174 | } 175 | 176 | func checkErr(err error) { 177 | if err != nil { 178 | log.Fatal(err) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /example/cmd/projections/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "time" 10 | 11 | "github.com/aneshas/eventstore" 12 | "github.com/aneshas/eventstore-example/account" 13 | ) 14 | 15 | func main() { 16 | eventStore, err := eventstore.New( 17 | eventstore.NewJSONEncoder(account.Events...), 18 | eventstore.WithSQLiteDB("example.db"), 19 | ) 20 | checkErr(err) 21 | 22 | defer func() { 23 | _ = eventStore.Close() 24 | }() 25 | 26 | p := eventstore.NewProjector(eventStore) 27 | 28 | p.Add( 29 | NewConsoleOutputProjection(), 30 | NewJSONFileProjection("accounts.json"), 31 | ) 32 | 33 | log.Fatal(p.Run(context.Background())) 34 | } 35 | 36 | func checkErr(err error) { 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | } 41 | 42 | // NewConsoleOutputProjection constructs an example projection that outputs 43 | // new accounts to the console. It might as well be to any kind of 44 | // database, disk, memory etc... 45 | func NewConsoleOutputProjection() eventstore.Projection { 46 | return func(data eventstore.StoredEvent) error { 47 | switch data.Event.(type) { 48 | case account.NewAccountOpened: 49 | evt := data.Event.(account.NewAccountOpened) 50 | fmt.Printf("Account: #%s | Holder: <%s>\n", evt.AccountID, evt.Holder) 51 | 52 | default: 53 | fmt.Println("not interested in this event") 54 | } 55 | 56 | return nil 57 | } 58 | } 59 | 60 | // NewJSONFileProjection makes use of flush after projection in order to 61 | // periodically write accounts to a json file on disk 62 | func NewJSONFileProjection(fName string) eventstore.Projection { 63 | var accounts []string 64 | 65 | return eventstore.FlushAfter( 66 | func(data eventstore.StoredEvent) error { 67 | switch data.Event.(type) { 68 | case account.NewAccountOpened: 69 | evt := data.Event.(account.NewAccountOpened) 70 | accounts = append(accounts, fmt.Sprintf("Account: #%s Holder: %s", evt.AccountID, evt.Holder)) 71 | default: 72 | fmt.Println("not interested in this event") 73 | } 74 | 75 | return nil 76 | }, 77 | 78 | func() error { 79 | if len(accounts) == 0 { 80 | return nil 81 | } 82 | 83 | data, err := json.Marshal(accounts) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | err = os.WriteFile(fName, data, os.ModeAppend|os.ModePerm) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | return nil 94 | }, 95 | 96 | 3*time.Second, 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aneshas/eventstore-example 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/aneshas/eventstore v0.3.0 7 | github.com/google/uuid v1.6.0 8 | github.com/labstack/echo/v4 v4.12.0 9 | ) 10 | 11 | require ( 12 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 13 | github.com/jackc/pgpassfile v1.0.0 // indirect 14 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 15 | github.com/jackc/pgx/v5 v5.7.1 // indirect 16 | github.com/jackc/puddle/v2 v2.2.2 // indirect 17 | github.com/jinzhu/inflection v1.0.0 // indirect 18 | github.com/jinzhu/now v1.1.5 // indirect 19 | github.com/labstack/gommon v0.4.2 // indirect 20 | github.com/mattn/go-colorable v0.1.13 // indirect 21 | github.com/mattn/go-isatty v0.0.20 // indirect 22 | github.com/mattn/go-sqlite3 v1.14.24 // indirect 23 | github.com/relvacode/iso8601 v1.4.0 // indirect 24 | github.com/valyala/bytebufferpool v1.0.0 // indirect 25 | github.com/valyala/fasttemplate v1.2.2 // indirect 26 | golang.org/x/crypto v0.28.0 // indirect 27 | golang.org/x/net v0.30.0 // indirect 28 | golang.org/x/sync v0.10.0 // indirect 29 | golang.org/x/sys v0.26.0 // indirect 30 | golang.org/x/text v0.21.0 // indirect 31 | golang.org/x/time v0.5.0 // indirect 32 | gorm.io/driver/postgres v1.5.11 // indirect 33 | gorm.io/driver/sqlite v1.5.6 // indirect 34 | gorm.io/gorm v1.25.12 // indirect 35 | ) 36 | 37 | replace github.com/aneshas/eventstore => ../../eventstore 38 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= 4 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 5 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 6 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 7 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 8 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 9 | github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= 10 | github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= 11 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 12 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 13 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= 14 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 15 | github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= 16 | github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 21 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 22 | github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= 23 | github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 24 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 25 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 26 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 27 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 28 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 29 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 30 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 31 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 32 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 33 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 34 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 35 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 36 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 37 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 38 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 39 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 40 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 41 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 42 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 43 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 44 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 45 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 46 | github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= 47 | github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= 48 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 49 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 50 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 51 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 52 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 53 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 54 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 55 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 56 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= 57 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= 58 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 59 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 60 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 61 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 62 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 63 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 64 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 65 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 66 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 67 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 68 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 69 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 70 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 71 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 72 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 73 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 74 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 75 | github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= 76 | github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= 77 | github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= 78 | github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= 79 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 80 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 81 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 82 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 83 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 84 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 85 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 86 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 87 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 88 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 89 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 90 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 91 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 92 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 93 | github.com/relvacode/iso8601 v1.4.0 h1:GsInVSEJfkYuirYFxa80nMLbH2aydgZpIf52gYZXUJs= 94 | github.com/relvacode/iso8601 v1.4.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= 95 | github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= 96 | github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= 97 | github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= 98 | github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= 99 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 100 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 101 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 102 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 103 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 104 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 105 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 106 | github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= 107 | github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= 108 | github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0 h1:c+Gt+XLJjqFAejgX4hSpnHIpC9eAhvgI/TFWL/PbrFI= 109 | github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0/go.mod h1:I4DazHBoWDyf69ByOIyt3OdNjefiUx372459txOpQ3o= 110 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 111 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 112 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 113 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 114 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 115 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 116 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 117 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 118 | github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= 119 | github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 120 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 121 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 122 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 123 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 124 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 125 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 126 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 127 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 128 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 129 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 130 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 131 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 132 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 133 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 134 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 135 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 137 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 138 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 139 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 140 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 141 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 142 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 143 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 144 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 145 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 146 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 147 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 148 | gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= 149 | gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 150 | gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 151 | gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= 152 | gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= 153 | gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= 154 | gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 155 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aneshas/eventstore 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/aneshas/tx/v2 v2.3.0 7 | github.com/google/uuid v1.6.0 8 | github.com/labstack/echo/v4 v4.12.0 9 | github.com/relvacode/iso8601 v1.4.0 10 | github.com/stretchr/testify v1.9.0 11 | github.com/testcontainers/testcontainers-go v0.33.0 12 | github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0 13 | gorm.io/driver/postgres v1.5.11 14 | gorm.io/driver/sqlite v1.5.6 15 | gorm.io/gorm v1.25.12 16 | ) 17 | 18 | require ( 19 | dario.cat/mergo v1.0.0 // indirect 20 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 21 | github.com/Microsoft/go-winio v0.6.2 // indirect 22 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 23 | github.com/containerd/containerd v1.7.18 // indirect 24 | github.com/containerd/log v0.1.0 // indirect 25 | github.com/containerd/platforms v0.2.1 // indirect 26 | github.com/cpuguy83/dockercfg v0.3.1 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/distribution/reference v0.6.0 // indirect 29 | github.com/docker/docker v27.1.1+incompatible // indirect 30 | github.com/docker/go-connections v0.5.0 // indirect 31 | github.com/docker/go-units v0.5.0 // indirect 32 | github.com/felixge/httpsnoop v1.0.4 // indirect 33 | github.com/go-logr/logr v1.4.1 // indirect 34 | github.com/go-logr/stdr v1.2.2 // indirect 35 | github.com/go-ole/go-ole v1.2.6 // indirect 36 | github.com/gogo/protobuf v1.3.2 // indirect 37 | github.com/jackc/pgpassfile v1.0.0 // indirect 38 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 39 | github.com/jackc/pgx/v5 v5.7.1 // indirect 40 | github.com/jackc/puddle/v2 v2.2.2 // indirect 41 | github.com/jinzhu/inflection v1.0.0 // indirect 42 | github.com/jinzhu/now v1.1.5 // indirect 43 | github.com/klauspost/compress v1.17.4 // indirect 44 | github.com/labstack/gommon v0.4.2 // indirect 45 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 46 | github.com/magiconair/properties v1.8.7 // indirect 47 | github.com/mattn/go-colorable v0.1.13 // indirect 48 | github.com/mattn/go-isatty v0.0.20 // indirect 49 | github.com/mattn/go-sqlite3 v1.14.24 // indirect 50 | github.com/moby/docker-image-spec v1.3.1 // indirect 51 | github.com/moby/patternmatcher v0.6.0 // indirect 52 | github.com/moby/sys/sequential v0.5.0 // indirect 53 | github.com/moby/sys/user v0.1.0 // indirect 54 | github.com/moby/term v0.5.0 // indirect 55 | github.com/morikuni/aec v1.0.0 // indirect 56 | github.com/opencontainers/go-digest v1.0.0 // indirect 57 | github.com/opencontainers/image-spec v1.1.0 // indirect 58 | github.com/pkg/errors v0.9.1 // indirect 59 | github.com/pmezard/go-difflib v1.0.0 // indirect 60 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 61 | github.com/shirou/gopsutil/v3 v3.23.12 // indirect 62 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 63 | github.com/sirupsen/logrus v1.9.3 // indirect 64 | github.com/tklauser/go-sysconf v0.3.12 // indirect 65 | github.com/tklauser/numcpus v0.6.1 // indirect 66 | github.com/valyala/bytebufferpool v1.0.0 // indirect 67 | github.com/valyala/fasttemplate v1.2.2 // indirect 68 | github.com/yusufpapurcu/wmi v1.2.3 // indirect 69 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 70 | go.opentelemetry.io/otel v1.24.0 // indirect 71 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 72 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 73 | golang.org/x/crypto v0.28.0 // indirect 74 | golang.org/x/net v0.26.0 // indirect 75 | golang.org/x/sync v0.10.0 // indirect 76 | golang.org/x/sys v0.26.0 // indirect 77 | golang.org/x/text v0.21.0 // indirect 78 | gopkg.in/yaml.v3 v3.0.1 // indirect 79 | ) 80 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= 4 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 5 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 6 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 7 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 8 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 9 | github.com/aneshas/tx/v2 v2.3.0 h1:GQYoGXQjnoSMiv9Go6L8bec2E7u/OOrS8Km3Gs0NHME= 10 | github.com/aneshas/tx/v2 v2.3.0/go.mod h1:n5QbsKIrPzuDGF5EQy/jYMjbU1BiCxlxJRgUQ0iKhFI= 11 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 12 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 13 | github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= 14 | github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= 15 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 16 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 17 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= 18 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 19 | github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= 20 | github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 21 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 22 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 27 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 28 | github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= 29 | github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 30 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 31 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 32 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 33 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 34 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 35 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 36 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 37 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 38 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 39 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 40 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 41 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 42 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 43 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 44 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 45 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 46 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 47 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 48 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 49 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 50 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 51 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= 52 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= 53 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 54 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 55 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 56 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 57 | github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= 58 | github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= 59 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 60 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 61 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 62 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 63 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 64 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 65 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 66 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 67 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 68 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 69 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 70 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 71 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 72 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 73 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= 74 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= 75 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 76 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 77 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 78 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 79 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 80 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 81 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 82 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 83 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 84 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 85 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 86 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 87 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 88 | github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= 89 | github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 90 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 91 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 92 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 93 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 94 | github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= 95 | github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= 96 | github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= 97 | github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= 98 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 99 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 100 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 101 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 102 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 103 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 104 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 105 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 106 | github.com/orlangure/gnomock v0.30.0 h1:WXq/3KTKRVYe9a3BXa5JMZCCrg2RwNAPB2bZHMxEntE= 107 | github.com/orlangure/gnomock v0.30.0/go.mod h1:vDur9icFVsecjDQrHn06SbUs0BXjJaNJRDexBsPh5f4= 108 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 109 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 110 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 111 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 112 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 113 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 114 | github.com/relvacode/iso8601 v1.4.0 h1:GsInVSEJfkYuirYFxa80nMLbH2aydgZpIf52gYZXUJs= 115 | github.com/relvacode/iso8601 v1.4.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= 116 | github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= 117 | github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= 118 | github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= 119 | github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= 120 | github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= 121 | github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= 122 | github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= 123 | github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= 124 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 125 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 126 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 127 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 128 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 129 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 130 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 131 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 132 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 133 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 134 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 135 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 136 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 137 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 138 | github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= 139 | github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= 140 | github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0 h1:c+Gt+XLJjqFAejgX4hSpnHIpC9eAhvgI/TFWL/PbrFI= 141 | github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0/go.mod h1:I4DazHBoWDyf69ByOIyt3OdNjefiUx372459txOpQ3o= 142 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 143 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 144 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 145 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 146 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 147 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 148 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 149 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 150 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 151 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 152 | github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= 153 | github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 154 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 155 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 156 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 157 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 158 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= 159 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= 160 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= 161 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= 162 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 163 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 164 | go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= 165 | go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= 166 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 167 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 168 | go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= 169 | go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= 170 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 171 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 172 | go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= 173 | go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= 174 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 175 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 176 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 177 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 178 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 179 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 180 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 181 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 182 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 183 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 184 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 185 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 186 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 187 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 188 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 189 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 190 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 191 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 192 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 193 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 194 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 195 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 196 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 197 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 198 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 199 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 200 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 201 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 202 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 203 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 204 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 205 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 206 | golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= 207 | golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= 208 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 209 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 210 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 211 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 212 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 213 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 214 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 215 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 216 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 217 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 218 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 219 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 220 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 221 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 222 | google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0= 223 | google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= 224 | google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= 225 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= 226 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= 227 | google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= 228 | google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= 229 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 230 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 231 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 232 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 233 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 234 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 235 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 236 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 237 | gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= 238 | gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 239 | gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= 240 | gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= 241 | gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= 242 | gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= 243 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 244 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 245 | -------------------------------------------------------------------------------- /json_encoder.go: -------------------------------------------------------------------------------- 1 | package eventstore 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | ) 7 | 8 | // NewJSONEncoder constructs json encoder 9 | // It receives a slice of event types it should be able to encode/decode 10 | func NewJSONEncoder(events ...any) *JsonEncoder { 11 | enc := JsonEncoder{ 12 | types: make(map[string]reflect.Type), 13 | } 14 | 15 | for _, evt := range events { 16 | t := reflect.TypeOf(evt) 17 | enc.types[t.Name()] = t 18 | } 19 | 20 | return &enc 21 | } 22 | 23 | // JsonEncoder provides default json Encoder implementation 24 | // It will marshal and unmarshal events to/from json and store the type name 25 | type JsonEncoder struct { 26 | types map[string]reflect.Type 27 | } 28 | 29 | // Encode marshals incoming event to it's json representation 30 | func (e *JsonEncoder) Encode(evt any) (*EncodedEvt, error) { 31 | data, err := json.Marshal(evt) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return &EncodedEvt{ 37 | Type: reflect.TypeOf(evt).Name(), 38 | Data: string(data), 39 | }, nil 40 | } 41 | 42 | // Decode decodes incoming event to it's corresponding go type 43 | func (e *JsonEncoder) Decode(evt *EncodedEvt) (any, error) { 44 | t, ok := e.types[evt.Type] 45 | if !ok { 46 | return nil, ErrEventNotRegistered 47 | } 48 | 49 | v := reflect.New(t) 50 | 51 | err := json.Unmarshal([]byte(evt.Data), v.Interface()) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return v.Elem().Interface(), nil 57 | } 58 | -------------------------------------------------------------------------------- /json_encoder_test.go: -------------------------------------------------------------------------------- 1 | package eventstore_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/aneshas/eventstore" 8 | ) 9 | 10 | type AnotherEvent struct { 11 | Smth string 12 | } 13 | 14 | func TestShouldDecodeEncodedEvent(t *testing.T) { 15 | enc := eventstore.NewJSONEncoder(SomeEvent{}, AnotherEvent{}) 16 | 17 | decodeEncode(t, enc, SomeEvent{ 18 | UserID: "some-user", 19 | }) 20 | 21 | decodeEncode(t, enc, AnotherEvent{ 22 | Smth: "foo", 23 | }) 24 | } 25 | 26 | func decodeEncode(t *testing.T, enc eventstore.Encoder, e interface{}) { 27 | encoded, err := enc.Encode(e) 28 | if err != nil { 29 | t.Fatalf("%v", err) 30 | } 31 | 32 | decoded, err := enc.Decode(encoded) 33 | if err != nil { 34 | t.Fatalf("%v", err) 35 | } 36 | 37 | if !reflect.DeepEqual(e, decoded) { 38 | t.Fatalf("event not decoded. want: %#v, got: %#v", e, decoded) 39 | } 40 | } 41 | 42 | func TestShouldErrorOutIfMalformedJSON(t *testing.T) { 43 | enc := eventstore.NewJSONEncoder(SomeEvent{}, AnotherEvent{}) 44 | 45 | _, err := enc.Decode(&eventstore.EncodedEvt{ 46 | Data: "malformed-json", 47 | Type: "SomeEvent", 48 | }) 49 | if err == nil { 50 | t.Fatal("should error out") 51 | } 52 | } 53 | 54 | func TestShouldErrorOutIfEvtTypeUnknown(t *testing.T) { 55 | enc := eventstore.NewJSONEncoder(SomeEvent{}, AnotherEvent{}) 56 | 57 | _, err := enc.Decode(&eventstore.EncodedEvt{ 58 | Data: `{"userId": "123"}`, 59 | Type: "unknown", 60 | }) 61 | if err == nil { 62 | t.Fatal("should error out") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /projection.go: -------------------------------------------------------------------------------- 1 | package eventstore 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "log" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // EventStreamer represents an event stream that can be subscribed to 13 | // This package offers EventStore as EventStreamer implementation 14 | type EventStreamer interface { 15 | SubscribeAll(context.Context, ...SubAllOpt) (Subscription, error) 16 | } 17 | 18 | // NewProjector constructs a Projector 19 | // TODO Configure logger, pollInterval, and retry 20 | func NewProjector(s EventStreamer) *Projector { 21 | return &Projector{ 22 | streamer: s, 23 | logger: log.Default(), 24 | } 25 | } 26 | 27 | // Projector is an event projector which will subscribe to an 28 | // event stream (evet store) and project events to each 29 | // individual projection in an asynchronous manner 30 | type Projector struct { 31 | streamer EventStreamer 32 | projections []Projection 33 | logger *log.Logger 34 | } 35 | 36 | // Projection is basically a function which needs to handle a stored event. 37 | // It will be called for each event that comes in 38 | type Projection func(StoredEvent) error 39 | 40 | // Add effectively registers a projection with the projector 41 | // Make sure to add all of your projections before calling Run 42 | func (p *Projector) Add(projections ...Projection) { 43 | p.projections = append(p.projections, projections...) 44 | } 45 | 46 | // Run will start the projector 47 | func (p *Projector) Run(ctx context.Context) error { 48 | var wg sync.WaitGroup 49 | 50 | for _, projection := range p.projections { 51 | wg.Add(1) 52 | 53 | go func(projection Projection) { 54 | defer wg.Done() 55 | 56 | for { 57 | // TODO retry with backoff 58 | sub, err := p.streamer.SubscribeAll(ctx) 59 | if err != nil { 60 | p.logErr(err) 61 | 62 | return 63 | } 64 | 65 | if err := p.run(ctx, sub, projection); err != nil { 66 | sub.Close() 67 | 68 | continue 69 | } 70 | 71 | sub.Close() 72 | 73 | return 74 | } 75 | }(projection) 76 | } 77 | 78 | wg.Wait() 79 | 80 | return nil 81 | } 82 | 83 | func (p *Projector) run(ctx context.Context, sub Subscription, projection Projection) error { 84 | for { 85 | select { 86 | case data := <-sub.EventData: 87 | err := projection(data) 88 | if err != nil { 89 | p.logErr(err) 90 | // TODO retry with backoff 91 | 92 | return err 93 | } 94 | 95 | case err := <-sub.Err: 96 | if err != nil { 97 | if errors.Is(err, io.EOF) { 98 | break 99 | } 100 | 101 | if errors.Is(err, ErrSubscriptionClosedByClient) { 102 | return nil 103 | } 104 | 105 | p.logErr(err) 106 | } 107 | 108 | case <-ctx.Done(): 109 | return nil 110 | } 111 | } 112 | } 113 | 114 | func (p *Projector) logErr(err error) { 115 | p.logger.Printf("projector error: %v", err) 116 | } 117 | 118 | // FlushAfter wraps the projection passed in, and it calls 119 | // the projection itself as new events come (as usual) in addition to calling 120 | // the provided flush function periodically each time flush interval expires 121 | func FlushAfter( 122 | p Projection, 123 | flush func() error, 124 | flushInt time.Duration) Projection { 125 | work := make(chan StoredEvent, 1) 126 | errs := make(chan error, 2) 127 | 128 | go func() { 129 | for { 130 | select { 131 | case <-time.After(flushInt): 132 | if err := flush(); err != nil { 133 | errs <- err 134 | } 135 | 136 | case w := <-work: 137 | if err := p(w); err != nil { 138 | errs <- err 139 | } 140 | } 141 | } 142 | }() 143 | 144 | return func(data StoredEvent) error { 145 | select { 146 | case err := <-errs: 147 | return err 148 | 149 | default: 150 | work <- data 151 | } 152 | 153 | return nil 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /projection_test.go: -------------------------------------------------------------------------------- 1 | package eventstore_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "reflect" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/aneshas/eventstore" 13 | ) 14 | 15 | type streamer struct { 16 | evts []interface{} 17 | err error 18 | streamErr error 19 | noClose bool 20 | delay *time.Duration 21 | } 22 | 23 | func (s streamer) SubscribeAll(ctx context.Context, opts ...eventstore.SubAllOpt) (eventstore.Subscription, error) { 24 | if s.err != nil { 25 | return eventstore.Subscription{}, s.err 26 | } 27 | 28 | sub := eventstore.Subscription{ 29 | Err: make(chan error, 1), 30 | EventData: make(chan eventstore.StoredEvent), 31 | } 32 | 33 | go func() { 34 | if s.delay != nil { 35 | time.Sleep(*s.delay) 36 | } 37 | 38 | for _, evt := range s.evts { 39 | sub.EventData <- eventstore.StoredEvent{ 40 | Event: evt, 41 | } 42 | 43 | if s.streamErr != nil { 44 | sub.Err <- s.streamErr 45 | continue 46 | } 47 | 48 | sub.Err <- io.EOF 49 | } 50 | 51 | if !s.noClose { 52 | sub.Err <- eventstore.ErrSubscriptionClosedByClient 53 | } 54 | }() 55 | 56 | return sub, nil 57 | } 58 | 59 | func TestShouldProjectEventsToProjections(t *testing.T) { 60 | evts := []interface{}{ 61 | SomeEvent{ 62 | UserID: "user-1", 63 | }, 64 | SomeEvent{ 65 | UserID: "user-2", 66 | }, 67 | SomeEvent{ 68 | UserID: "user-3", 69 | }, 70 | } 71 | 72 | s := streamer{ 73 | evts: evts, 74 | } 75 | 76 | p := eventstore.NewProjector(s) 77 | 78 | var got []interface{} 79 | var anotherGot []interface{} 80 | 81 | p.Add( 82 | func(ed eventstore.StoredEvent) error { 83 | got = append(got, ed.Event) 84 | 85 | return nil 86 | }, 87 | func(ed eventstore.StoredEvent) error { 88 | anotherGot = append(anotherGot, ed.Event) 89 | 90 | return nil 91 | }, 92 | ) 93 | 94 | p.Run(context.TODO()) 95 | 96 | if !reflect.DeepEqual(got, evts) || 97 | !reflect.DeepEqual(anotherGot, evts) { 98 | t.Fatal("all projections should have received all events") 99 | } 100 | } 101 | 102 | func TestShouldRetryAndRestartIfProjectionErrorsOut(t *testing.T) { 103 | evts := []interface{}{ 104 | SomeEvent{ 105 | UserID: "user-1", 106 | }, 107 | } 108 | 109 | s := streamer{ 110 | evts: evts, 111 | } 112 | 113 | p := eventstore.NewProjector(s) 114 | 115 | var got []interface{} 116 | 117 | var times int 118 | 119 | p.Add( 120 | func(ed eventstore.StoredEvent) error { 121 | if times < 3 { 122 | times++ 123 | return fmt.Errorf("some transient error") 124 | } 125 | 126 | got = append(got, ed.Event) 127 | 128 | return nil 129 | }, 130 | ) 131 | 132 | p.Run(context.TODO()) 133 | 134 | if !reflect.DeepEqual(got, evts) { 135 | t.Fatal("projection should have caught up after erroring out") 136 | } 137 | } 138 | 139 | func TestShouldRetrySubscriptionIfProjectionFailsToSubscribe(t *testing.T) { 140 | someErr := fmt.Errorf("some terminal error") 141 | 142 | s := streamer{ 143 | err: someErr, 144 | } 145 | 146 | p := eventstore.NewProjector(s) 147 | 148 | p.Add( 149 | func(ed eventstore.StoredEvent) error { 150 | return nil 151 | }, 152 | ) 153 | 154 | p.Run(context.TODO()) 155 | } 156 | 157 | func TestShouldExitIfContextIsCanceled(t *testing.T) { 158 | evts := []interface{}{ 159 | SomeEvent{ 160 | UserID: "user-1", 161 | }, 162 | } 163 | 164 | s := streamer{ 165 | evts: evts, 166 | noClose: true, 167 | } 168 | 169 | p := eventstore.NewProjector(s) 170 | 171 | p.Add( 172 | func(ed eventstore.StoredEvent) error { 173 | return nil 174 | }, 175 | func(ed eventstore.StoredEvent) error { 176 | return nil 177 | }, 178 | ) 179 | 180 | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) 181 | 182 | defer cancel() 183 | 184 | p.Run(ctx) 185 | } 186 | 187 | func TestShouldContinueProjectingIfStreamingErrorOccurs(t *testing.T) { 188 | evts := []interface{}{ 189 | SomeEvent{ 190 | UserID: "user-1", 191 | }, 192 | SomeEvent{ 193 | UserID: "user-2", 194 | }, 195 | SomeEvent{ 196 | UserID: "user-3", 197 | }, 198 | } 199 | 200 | s := streamer{ 201 | evts: evts, 202 | streamErr: fmt.Errorf("some error"), 203 | } 204 | 205 | p := eventstore.NewProjector(s) 206 | 207 | var got []interface{} 208 | 209 | p.Add( 210 | func(ed eventstore.StoredEvent) error { 211 | got = append(got, ed.Event) 212 | 213 | return nil 214 | }, 215 | ) 216 | 217 | p.Run(context.TODO()) 218 | 219 | if !reflect.DeepEqual(got, evts) { 220 | t.Fatal("projection should have caught up after erroring out") 221 | } 222 | } 223 | 224 | func TestShouldFlushProjection(t *testing.T) { 225 | evts := []interface{}{ 226 | SomeEvent{ 227 | UserID: "user-1", 228 | }, 229 | SomeEvent{ 230 | UserID: "user-2", 231 | }, 232 | SomeEvent{ 233 | UserID: "user-3", 234 | }, 235 | } 236 | 237 | d := 500 * time.Millisecond 238 | 239 | s := streamer{ 240 | evts: evts, 241 | delay: &d, 242 | } 243 | 244 | p := eventstore.NewProjector(s) 245 | 246 | var m sync.Mutex 247 | var got []interface{} 248 | 249 | called := false 250 | 251 | p.Add( 252 | eventstore.FlushAfter( 253 | func(ed eventstore.StoredEvent) error { 254 | m.Lock() 255 | defer m.Unlock() 256 | 257 | got = append(got, ed.Event) 258 | 259 | return nil 260 | }, 261 | func() error { 262 | m.Lock() 263 | defer m.Unlock() 264 | 265 | called = true 266 | 267 | return nil 268 | }, 269 | 200*time.Millisecond, 270 | ), 271 | ) 272 | 273 | p.Run(context.TODO()) 274 | 275 | <-time.After(1000 * time.Millisecond) 276 | 277 | m.Lock() 278 | defer m.Unlock() 279 | 280 | if !reflect.DeepEqual(got, evts) { 281 | t.Fatal("projection should have received all events") 282 | } 283 | 284 | if !called { 285 | t.Fatal("flush should have been called") 286 | } 287 | } 288 | --------------------------------------------------------------------------------