├── .gitignore ├── .travis.yml ├── README.md ├── _examples ├── README.md ├── dynamo │ ├── compiles_test.go │ └── main.go ├── gopherfest │ ├── compiles_test.go │ └── main.go ├── protobuf │ ├── README.md │ ├── events.pb.es.go │ ├── events.pb.go │ ├── events.proto │ └── main.go └── simple │ ├── compiles_test.go │ └── main.go ├── awscloud ├── clients.go ├── clients_test.go └── compiles_test.go ├── cmd └── eventsource │ ├── compiles_test.go │ ├── dynamodb │ ├── compiles_test.go │ ├── create_table.go │ ├── delete_table.go │ └── options.go │ ├── main.go │ └── singleton │ ├── compiles_test.go │ ├── create_table.go │ ├── delete_table.go │ └── options.go ├── command.go ├── command_test.go ├── docker-compose.yml ├── dynamodbstore ├── changes.go ├── changes_test.go ├── error.go ├── infra.go ├── infra_test.go ├── key.go ├── key_test.go ├── option.go ├── store.go ├── store_inline_test.go ├── store_test.go └── util_test.go ├── error.go ├── error_test.go ├── event.go ├── event_test.go ├── mysqlstore ├── infra.go ├── store.go ├── store_test.go └── util_test.go ├── pgstore ├── infra.go ├── store.go ├── store_test.go └── util_test.go ├── repository.go ├── repository_test.go ├── scenario ├── scenario.go └── scenario_test.go ├── serializer.go ├── serializer_test.go ├── singleton ├── README.md ├── dispatcher.go ├── infra.go ├── singleton.go ├── singleton_test.go └── util_test.go ├── store.go ├── store_test.go ├── stream.go ├── util.go └── util_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore intellij files 2 | # 3 | .idea 4 | *.iml 5 | *.ipr 6 | *.iws 7 | 8 | # ignore local configs 9 | # 10 | *.env 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.8 5 | 6 | install: 7 | - go get github.com/apex/go-apex 8 | - go get github.com/aws/aws-sdk-go 9 | - go get github.com/pkg/errors 10 | - go get github.com/stretchr/testify 11 | - go get gopkg.in/urfave/cli.v1 12 | - go get github.com/go-sql-driver/mysql 13 | - go get github.com/go-sql-driver/mysql 14 | - go get github.com/lib/pq 15 | 16 | services: 17 | - mysql 18 | - postgresql 19 | 20 | env: 21 | - MYSQL_TEST_USER=root MYSQL_TEST_PASS="" MYSQL_TEST_PROT="" POSTGRES_TEST_USER=postgres POSTGRES_TEST_PASS="" 22 | 23 | before_script: 24 | - psql -c 'CREATE DATABASE altairsix;' -U postgres 25 | - mysql -e 'CREATE DATABASE IF NOT EXISTS altairsix;' 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/altairsix/eventsource?status.svg)](https://godoc.org/github.com/altairsix/eventsource) ![Travis CI](https://travis-ci.org/altairsix/eventsource.svg?branch=master) 2 | 3 | ```eventsource``` is a Serverless event sourcing library for Go that attempts to 4 | leverage the capabilities of AWS to simplify the development and operational 5 | requirements for event sourcing projects. 6 | 7 | This library is still under development and changes to the core api are likely. 8 | 9 | Take advantage of the scalability, high availability, clustering, and strong 10 | security model you've come to know and love with AWS. 11 | 12 | Serverless and accessible were significant design considerations in the creation of this 13 | library. What AWS can handle, I'd rather have AWS handle. 14 | 15 | ## Installation 16 | 17 | ``` 18 | go get github.com/altairsix/eventsource/... 19 | ``` 20 | 21 | ## Getting Started 22 | 23 | The easiest way to get started is to check out the _examples directory. gopherfest has the 24 | most complete example. Before you run the gopherfest example, you'll need to (a) ensure you 25 | have AWS credentials loaded into your environment and (b) then run: 26 | 27 | ``` 28 | eventsource dynamodb create-table --name orders --region us-west-2 29 | ``` 30 | 31 | ## Key Concepts 32 | 33 | Event sourcing is the idea that rather than storing the current state of a domain 34 | model into the database, you can instead store the sequence of events (or facts) 35 | and then rebuild the domain model from those facts. 36 | 37 | git is a great analogy. each commit becomes an event and when you clone or pull 38 | the repo, git uses that sequence of commits (events) to rebuild the project 39 | file structure (the domain model). 40 | 41 | Greg Young has an excellent primer on event sourcing that can found on the 42 | [EventStore docs page](http://docs.geteventstore.com/introduction/4.0.0/event-sourcing-basics/). 43 | 44 | ![Overview](https://s3.amazonaws.com/site-eventsource/Overview.png) 45 | 46 | ### Event 47 | 48 | Events represent domain events and should be expressed in the past tense such as CustomerMoved, 49 | OrderShipped, or EmailAddressChanged. These are irrefutable facts that have completed in the 50 | past. 51 | 52 | Try to avoid sticking derived values into the events as (a) events are long lived and bugs in the 53 | events will cause you great grief and (b) business rules change over time, sometimes retroactively. 54 | 55 | ### Aggregate 56 | 57 | The Aggregate (often called Aggregate Root) represents the domain modeled by the bounded context 58 | and represents the current state of our domain model. 59 | 60 | ### Repository 61 | 62 | Provides the data access layer to store and retrieve events into a persistent store. 63 | 64 | ### Store 65 | 66 | Represents the underlying data storage mechanism. eventsource only supports dynamodb out of the 67 | box, but there's no reason future versions could not support other database technologies like 68 | MySQL, Postgres or Mongodb. 69 | 70 | ### Serializer 71 | 72 | Specifies how events should be serialized. eventsource currently uses simple JSON serialization 73 | although I have some thoughts to support avro in the future. 74 | 75 | ### CommandHandler 76 | 77 | CommandHandlers are responsible for accepting (or rejecting) commands and emitting events. By 78 | convention, the struct that implements Aggregate should also implement CommandHandler. 79 | 80 | ### Command 81 | 82 | An active verb that represents the mutation one wishes to perform on the aggregate. 83 | 84 | ### Dispatcher 85 | 86 | Responsible for retrieving or instantiates the aggregate, executes the command, and saving the 87 | the resulting event(s) back to the repository. 88 | 89 | ## Creating dynamodb tables 90 | 91 | Eventsource comes with a utility to simplify creating / deleting the dynamodb tables. 92 | 93 | ``` 94 | eventsource dynamodb create-table --name {table-name} 95 | eventsource dynamodb delete-table --name {table-name} 96 | ``` 97 | 98 | ## Development 99 | 100 | To run the tests locally, execute the following: 101 | 102 | ``` 103 | docker-compose up 104 | export DYNAMODB_ENDPOINT=http://localhost:8080 105 | go test ./... 106 | ``` 107 | 108 | ## Testing 109 | 110 | The ```scenario``` package simplifies testing. 111 | 112 | ```go 113 | scenario.New(t, &Order{}). // &Order{} implements both Aggregate and CommandHandler 114 | Given(). // an initial set of events 115 | When(&CreateOrder{}). // command is applied 116 | Then(&OrderCreated{}) // expect the following events to be emitted 117 | ``` 118 | 119 | ### Todo 120 | 121 | - [ ] document singleton usage 122 | - [ ] implement dynamodb to sns lambda function 123 | - [ ] implement dynamodb to kinesis firehose lambda function 124 | - [ ] document stream replay via s3 125 | - [ ] add support for terraform in tooling 126 | 127 | -------------------------------------------------------------------------------- /_examples/README.md: -------------------------------------------------------------------------------- 1 | ### Examples 2 | 3 | * ```gopherfest``` - provides a complete end to end example of the eventsource library 4 | * ```simple``` - basic event sourcing concepts using in memory storage 5 | * ```dynamodb``` - the same as simple, but backed by dynamodb -------------------------------------------------------------------------------- /_examples/dynamo/compiles_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /_examples/dynamo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/altairsix/eventsource" 10 | "github.com/altairsix/eventsource/dynamodbstore" 11 | ) 12 | 13 | // UserCreated defines a user creation event 14 | type UserCreated struct { 15 | eventsource.Model 16 | } 17 | 18 | // UserFirstSet defines an event by simple struct embedding 19 | type UserNameSet struct { 20 | eventsource.Model 21 | Name string 22 | } 23 | 24 | // UserEmailSet implements the eventsource.Event interface directly rather than using the eventsource.Model helper 25 | type UserEmailSet struct { 26 | ID string 27 | Version int 28 | At time.Time 29 | Email string 30 | } 31 | 32 | func (m UserEmailSet) AggregateID() string { 33 | return m.ID 34 | } 35 | 36 | func (m UserEmailSet) EventVersion() int { 37 | return m.Version 38 | } 39 | 40 | func (m UserEmailSet) EventAt() time.Time { 41 | return m.At 42 | } 43 | 44 | type User struct { 45 | ID string 46 | Version int 47 | Name string 48 | Email string 49 | } 50 | 51 | func (item *User) On(event eventsource.Event) error { 52 | switch v := event.(type) { 53 | case *UserCreated: 54 | item.Version = v.Model.Version 55 | item.ID = v.Model.ID 56 | 57 | case *UserNameSet: 58 | item.Version = v.Model.Version 59 | item.Name = v.Name 60 | 61 | case *UserEmailSet: 62 | item.Version = v.Version 63 | item.Email = v.Email 64 | 65 | default: 66 | return fmt.Errorf("unable to handle event, %v", v) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func main() { 73 | store, err := dynamodbstore.New("user_events", 74 | dynamodbstore.WithRegion("us-west-2"), 75 | ) 76 | if err != nil { 77 | log.Fatalln(err) 78 | } 79 | 80 | repo := eventsource.New(&User{}, 81 | eventsource.WithStore(store), 82 | eventsource.WithSerializer(eventsource.NewJSONSerializer( 83 | UserCreated{}, 84 | UserNameSet{}, 85 | UserEmailSet{}, 86 | )), 87 | ) 88 | 89 | id := "123" 90 | setNameEvent := &UserNameSet{ 91 | Model: eventsource.Model{ID: id, Version: 1}, 92 | Name: "Joe Public", 93 | } 94 | setEmailEvent := &UserEmailSet{ 95 | ID: id, 96 | Version: 2, 97 | Email: "joe.public@example.com", 98 | } 99 | 100 | ctx := context.Background() 101 | err = repo.Save(ctx, setEmailEvent, setNameEvent) 102 | if err != nil { 103 | log.Fatalln(err) 104 | } 105 | 106 | v, err := repo.Load(ctx, id) 107 | if err != nil { 108 | log.Fatalln(err) 109 | } 110 | 111 | user := v.(*User) 112 | fmt.Printf("Hello %v %v\n", user.Name, user.Email) // prints "Hello Joe Public joe.public@example.com" 113 | } 114 | -------------------------------------------------------------------------------- /_examples/gopherfest/compiles_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /_examples/gopherfest/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/altairsix/eventsource" 11 | "github.com/altairsix/eventsource/dynamodbstore" 12 | ) 13 | 14 | //Order is an example of state generated from left fold of events 15 | type Order struct { 16 | ID string 17 | Version int 18 | CreatedAt time.Time 19 | UpdatedAt time.Time 20 | State string 21 | } 22 | 23 | //OrderCreated event used a marker of order created 24 | type OrderCreated struct { 25 | eventsource.Model 26 | } 27 | 28 | //OrderShipped event used a marker of order shipped 29 | type OrderShipped struct { 30 | eventsource.Model 31 | } 32 | 33 | //On implements Aggregate interface 34 | func (item *Order) On(event eventsource.Event) error { 35 | switch v := event.(type) { 36 | case *OrderCreated: 37 | item.State = "created" 38 | 39 | case *OrderShipped: 40 | item.State = "shipped" 41 | 42 | default: 43 | return fmt.Errorf("unable to handle event, %v", v) 44 | } 45 | 46 | item.Version = event.EventVersion() 47 | item.ID = event.AggregateID() 48 | item.UpdatedAt = event.EventAt() 49 | 50 | return nil 51 | } 52 | 53 | //CreateOrder command 54 | type CreateOrder struct { 55 | eventsource.CommandModel 56 | } 57 | 58 | //ShipOrder command 59 | type ShipOrder struct { 60 | eventsource.CommandModel 61 | } 62 | 63 | //Apply implements the CommandHandler interface 64 | func (item *Order) Apply(ctx context.Context, command eventsource.Command) ([]eventsource.Event, error) { 65 | switch v := command.(type) { 66 | case *CreateOrder: 67 | orderCreated := &OrderCreated{ 68 | Model: eventsource.Model{ID: command.AggregateID(), Version: item.Version + 1, At: time.Now()}, 69 | } 70 | return []eventsource.Event{orderCreated}, nil 71 | 72 | case *ShipOrder: 73 | if item.State != "created" { 74 | return nil, fmt.Errorf("order, %v, has already shipped", command.AggregateID()) 75 | } 76 | orderShipped := &OrderShipped{ 77 | Model: eventsource.Model{ID: command.AggregateID(), Version: item.Version + 1, At: time.Now()}, 78 | } 79 | return []eventsource.Event{orderShipped}, nil 80 | 81 | default: 82 | return nil, fmt.Errorf("unhandled command, %v", v) 83 | } 84 | } 85 | 86 | func check(err error) { 87 | if err != nil { 88 | log.Fatalln(err) 89 | } 90 | } 91 | 92 | func main() { 93 | store, err := dynamodbstore.New("orders", 94 | dynamodbstore.WithRegion("us-west-2"), 95 | ) 96 | check(err) 97 | 98 | repo := eventsource.New(&Order{}, 99 | eventsource.WithStore(store), 100 | eventsource.WithSerializer(eventsource.NewJSONSerializer( 101 | OrderCreated{}, 102 | OrderShipped{}, 103 | )), 104 | ) 105 | 106 | id := strconv.FormatInt(time.Now().UnixNano(), 36) 107 | ctx := context.Background() 108 | 109 | _, err = repo.Apply(ctx, &CreateOrder{ 110 | CommandModel: eventsource.CommandModel{ID: id}, 111 | }) 112 | check(err) 113 | 114 | _, err = repo.Apply(ctx, &ShipOrder{ 115 | CommandModel: eventsource.CommandModel{ID: id}, 116 | }) 117 | check(err) 118 | 119 | aggregate, err := repo.Load(ctx, id) 120 | check(err) 121 | 122 | found := aggregate.(*Order) 123 | fmt.Printf("Order %v [version %v] %v %v\n", found.ID, found.Version, found.State, found.UpdatedAt.Format(time.RFC822)) 124 | } 125 | -------------------------------------------------------------------------------- /_examples/protobuf/README.md: -------------------------------------------------------------------------------- 1 | events.pb.es.go was generated from events.proto using the 2 | [github.com/altairsix/eventsource-protobuf](https://github.com/altairsix/eventsource-protobuf) package 3 | 4 | 5 | -------------------------------------------------------------------------------- /_examples/protobuf/events.pb.es.go: -------------------------------------------------------------------------------- 1 | // Code generated by eventsource-protobuf. DO NOT EDIT. 2 | // source: events.proto 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "time" 9 | 10 | "github.com/altairsix/eventsource" 11 | "github.com/gogo/protobuf/proto" 12 | ) 13 | 14 | type serializer struct { 15 | } 16 | 17 | func (s *serializer) MarshalEvent(event eventsource.Event) (eventsource.Record, error) { 18 | container := &ShoppingCart{} 19 | 20 | switch v := event.(type) { 21 | 22 | case *ItemAdded: 23 | container.Type = 2 24 | container.A = v 25 | 26 | case *ItemRemoved: 27 | container.Type = 3 28 | container.B = v 29 | 30 | default: 31 | return eventsource.Record{}, fmt.Errorf("Unhandled type, %v", event) 32 | } 33 | 34 | data, err := proto.Marshal(container) 35 | if err != nil { 36 | return eventsource.Record{}, err 37 | } 38 | 39 | return eventsource.Record{ 40 | Version: event.EventVersion(), 41 | Data: data, 42 | }, nil 43 | } 44 | 45 | func (s *serializer) UnmarshalEvent(record eventsource.Record) (eventsource.Event, error) { 46 | container := &ShoppingCart{}; 47 | err := proto.Unmarshal(record.Data, container) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | var event interface{} 53 | switch container.Type { 54 | 55 | case 2: 56 | event = container.A 57 | 58 | case 3: 59 | event = container.B 60 | 61 | default: 62 | return nil, fmt.Errorf("Unhandled type, %v", container.Type) 63 | } 64 | 65 | return event.(eventsource.Event), nil 66 | } 67 | 68 | func NewSerializer() eventsource.Serializer { 69 | return &serializer{} 70 | } 71 | 72 | func (m *ItemAdded) AggregateID() string { return m.Id } 73 | func (m *ItemAdded) EventVersion() int { return int(m.Version) } 74 | func (m *ItemAdded) EventAt() time.Time { return time.Unix(m.At, 0) } 75 | 76 | func (m *ItemRemoved) AggregateID() string { return m.Id } 77 | func (m *ItemRemoved) EventVersion() int { return int(m.Version) } 78 | func (m *ItemRemoved) EventAt() time.Time { return time.Unix(m.At, 0) } 79 | 80 | -------------------------------------------------------------------------------- /_examples/protobuf/events.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // source: events.proto 3 | 4 | /* 5 | Package main is a generated protocol buffer package. 6 | 7 | It is generated from these files: 8 | events.proto 9 | 10 | It has these top-level messages: 11 | ItemAdded 12 | ItemRemoved 13 | ShoppingCart 14 | */ 15 | package main 16 | 17 | import proto "github.com/golang/protobuf/proto" 18 | import fmt "fmt" 19 | import math "math" 20 | 21 | // Reference imports to suppress errors if they are not otherwise used. 22 | var _ = proto.Marshal 23 | var _ = fmt.Errorf 24 | var _ = math.Inf 25 | 26 | // This is a compile-time assertion to ensure that this generated file 27 | // is compatible with the proto package it is being compiled against. 28 | // A compilation error at this line likely means your copy of the 29 | // proto package needs to be updated. 30 | const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package 31 | 32 | type ItemAdded struct { 33 | Id string `protobuf:"bytes,1,opt,name=id" json:"id,omitempty"` 34 | Version int32 `protobuf:"varint,2,opt,name=version" json:"version,omitempty"` 35 | At int64 `protobuf:"varint,3,opt,name=at" json:"at,omitempty"` 36 | } 37 | 38 | func (m *ItemAdded) Reset() { *m = ItemAdded{} } 39 | func (m *ItemAdded) String() string { return proto.CompactTextString(m) } 40 | func (*ItemAdded) ProtoMessage() {} 41 | func (*ItemAdded) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } 42 | 43 | func (m *ItemAdded) GetId() string { 44 | if m != nil { 45 | return m.Id 46 | } 47 | return "" 48 | } 49 | 50 | func (m *ItemAdded) GetVersion() int32 { 51 | if m != nil { 52 | return m.Version 53 | } 54 | return 0 55 | } 56 | 57 | func (m *ItemAdded) GetAt() int64 { 58 | if m != nil { 59 | return m.At 60 | } 61 | return 0 62 | } 63 | 64 | type ItemRemoved struct { 65 | Id string `protobuf:"bytes,1,opt,name=id" json:"id,omitempty"` 66 | Version int32 `protobuf:"varint,2,opt,name=version" json:"version,omitempty"` 67 | At int64 `protobuf:"varint,3,opt,name=at" json:"at,omitempty"` 68 | } 69 | 70 | func (m *ItemRemoved) Reset() { *m = ItemRemoved{} } 71 | func (m *ItemRemoved) String() string { return proto.CompactTextString(m) } 72 | func (*ItemRemoved) ProtoMessage() {} 73 | func (*ItemRemoved) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{1} } 74 | 75 | func (m *ItemRemoved) GetId() string { 76 | if m != nil { 77 | return m.Id 78 | } 79 | return "" 80 | } 81 | 82 | func (m *ItemRemoved) GetVersion() int32 { 83 | if m != nil { 84 | return m.Version 85 | } 86 | return 0 87 | } 88 | 89 | func (m *ItemRemoved) GetAt() int64 { 90 | if m != nil { 91 | return m.At 92 | } 93 | return 0 94 | } 95 | 96 | type ShoppingCart struct { 97 | Type int32 `protobuf:"varint,1,opt,name=type" json:"type,omitempty"` 98 | A *ItemAdded `protobuf:"bytes,2,opt,name=a" json:"a,omitempty"` 99 | B *ItemRemoved `protobuf:"bytes,3,opt,name=b" json:"b,omitempty"` 100 | } 101 | 102 | func (m *ShoppingCart) Reset() { *m = ShoppingCart{} } 103 | func (m *ShoppingCart) String() string { return proto.CompactTextString(m) } 104 | func (*ShoppingCart) ProtoMessage() {} 105 | func (*ShoppingCart) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{2} } 106 | 107 | func (m *ShoppingCart) GetType() int32 { 108 | if m != nil { 109 | return m.Type 110 | } 111 | return 0 112 | } 113 | 114 | func (m *ShoppingCart) GetA() *ItemAdded { 115 | if m != nil { 116 | return m.A 117 | } 118 | return nil 119 | } 120 | 121 | func (m *ShoppingCart) GetB() *ItemRemoved { 122 | if m != nil { 123 | return m.B 124 | } 125 | return nil 126 | } 127 | 128 | func init() { 129 | proto.RegisterType((*ItemAdded)(nil), "main.item_added") 130 | proto.RegisterType((*ItemRemoved)(nil), "main.item_removed") 131 | proto.RegisterType((*ShoppingCart)(nil), "main.shopping_cart") 132 | } 133 | 134 | func init() { proto.RegisterFile("events.proto", fileDescriptor0) } 135 | 136 | var fileDescriptor0 = []byte{ 137 | // 189 bytes of a gzipped FileDescriptorProto 138 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xa4, 0xd0, 0xb1, 0x6a, 0xc4, 0x30, 139 | 0x0c, 0x06, 0x60, 0xe4, 0x24, 0x2d, 0x55, 0xd2, 0x52, 0x34, 0x79, 0x2a, 0x26, 0x53, 0xa6, 0x0c, 140 | 0xe9, 0x3b, 0x94, 0xce, 0x7e, 0x81, 0xe0, 0xd4, 0xa2, 0xe7, 0x21, 0xb6, 0x71, 0x4c, 0xe0, 0xde, 141 | 0xfe, 0x88, 0xb9, 0xe3, 0x6e, 0xbf, 0x4d, 0x42, 0xe2, 0x43, 0xbf, 0xb0, 0xe3, 0x9d, 0x7d, 0xde, 142 | 0xc6, 0x98, 0x42, 0x0e, 0x54, 0xaf, 0xc6, 0xf9, 0xfe, 0x07, 0xd1, 0x65, 0x5e, 0x67, 0x63, 0x2d, 143 | 0x5b, 0xfa, 0x40, 0xe1, 0xac, 0x04, 0x05, 0xc3, 0x9b, 0x16, 0xce, 0x92, 0xc4, 0xd7, 0x9d, 0xd3, 144 | 0xe6, 0x82, 0x97, 0x42, 0xc1, 0xd0, 0xe8, 0x5b, 0x7b, 0x6c, 0x9a, 0x2c, 0x2b, 0x05, 0x43, 0xa5, 145 | 0x85, 0xc9, 0xfd, 0x2f, 0x76, 0xc5, 0x49, 0xbc, 0x86, 0xfd, 0x29, 0x89, 0xf1, 0x7d, 0x3b, 0x85, 146 | 0x18, 0x9d, 0xff, 0x9f, 0xff, 0x4c, 0xca, 0x44, 0x58, 0xe7, 0x73, 0xe4, 0x82, 0x35, 0xba, 0xd4, 147 | 0xf4, 0x85, 0x60, 0x0a, 0xd4, 0x4e, 0x9f, 0xe3, 0x11, 0x64, 0xbc, 0xa7, 0xd0, 0x60, 0x48, 0x21, 148 | 0x2c, 0xc5, 0x6c, 0x27, 0x7a, 0x98, 0x5f, 0xaf, 0xd3, 0xb0, 0x2c, 0x2f, 0xe5, 0x0b, 0xdf, 0x97, 149 | 0x00, 0x00, 0x00, 0xff, 0xff, 0xd9, 0xed, 0xb8, 0x99, 0x15, 0x01, 0x00, 0x00, 150 | } 151 | -------------------------------------------------------------------------------- /_examples/protobuf/events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package main; 4 | 5 | message item_added { 6 | string id = 1; 7 | int32 version = 2; 8 | int64 at = 3; 9 | } 10 | 11 | message item_removed { 12 | string id = 1; 13 | int32 version = 2; 14 | int64 at = 3; 15 | } 16 | 17 | message shopping_cart { 18 | int32 type = 1; 19 | item_added a = 2; 20 | item_removed b = 3; 21 | } -------------------------------------------------------------------------------- /_examples/protobuf/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | //go:generate protoc --go_out=. events.proto 9 | //go:generate protoc --plugin=protoc-gen-custom=$GOPATH/bin/eventsource-protobuf --custom_out=. events.proto 10 | 11 | func check(err error) { 12 | if err != nil { 13 | log.Fatalln(err) 14 | } 15 | } 16 | 17 | func main() { 18 | serializer := NewSerializer() 19 | event := &ItemAdded{Id: "abc"} 20 | 21 | record, err := serializer.MarshalEvent(event) 22 | check(err) 23 | 24 | actual, err := serializer.UnmarshalEvent(record) 25 | check(err) 26 | 27 | if event.Id != actual.AggregateID() { 28 | check(fmt.Errorf("expected %#v; got %#v", event, actual)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /_examples/simple/compiles_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /_examples/simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/altairsix/eventsource" 10 | ) 11 | 12 | // UserCreated defines a user creation event 13 | type UserCreated struct { 14 | eventsource.Model 15 | } 16 | 17 | // UserFirstSet defines an event by simple struct embedding 18 | type UserNameSet struct { 19 | eventsource.Model 20 | Name string 21 | } 22 | 23 | // UserLastSet defines an event via tags 24 | type UserEmailSet struct { 25 | ID string 26 | Version int 27 | At time.Time 28 | Email string 29 | } 30 | 31 | func (m UserEmailSet) AggregateID() string { 32 | return m.ID 33 | } 34 | 35 | func (m UserEmailSet) EventVersion() int { 36 | return m.Version 37 | } 38 | 39 | func (m UserEmailSet) EventAt() time.Time { 40 | return m.At 41 | } 42 | 43 | type User struct { 44 | ID string 45 | Version int 46 | Name string 47 | Email string 48 | } 49 | 50 | func (item *User) On(event eventsource.Event) error { 51 | switch v := event.(type) { 52 | case *UserCreated: 53 | item.Version = v.Model.Version 54 | item.ID = v.Model.ID 55 | 56 | case *UserNameSet: 57 | item.Version = v.Model.Version 58 | item.Name = v.Name 59 | 60 | case *UserEmailSet: 61 | item.Version = v.Version 62 | item.Email = v.Email 63 | 64 | default: 65 | return fmt.Errorf("unhandled event, %v", v) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func main() { 72 | serializer := eventsource.NewJSONSerializer( 73 | UserCreated{}, 74 | UserNameSet{}, 75 | UserEmailSet{}, 76 | ) 77 | repo := eventsource.New(&User{}, 78 | eventsource.WithSerializer(serializer), 79 | ) 80 | 81 | id := "123" 82 | setNameEvent := &UserNameSet{ 83 | Model: eventsource.Model{ID: id, Version: 1}, 84 | Name: "Joe Public", 85 | } 86 | setEmailEvent := &UserEmailSet{ 87 | ID: id, 88 | Version: 2, 89 | Email: "joe.public@example.com", 90 | } 91 | 92 | ctx := context.Background() 93 | err := repo.Save(ctx, setEmailEvent, setNameEvent) 94 | if err != nil { 95 | log.Fatalln(err) 96 | } 97 | 98 | v, err := repo.Load(ctx, id) 99 | if err != nil { 100 | log.Fatalln(err) 101 | } 102 | 103 | user := v.(*User) 104 | fmt.Printf("Hello %v %v\n", user.Name, user.Email) // prints "Hello Joe Public joe.public@example.com" 105 | } 106 | -------------------------------------------------------------------------------- /awscloud/clients.go: -------------------------------------------------------------------------------- 1 | package awscloud 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/dynamodb" 9 | "github.com/aws/aws-sdk-go/service/firehose" 10 | ) 11 | 12 | // DynamoDB constructs a new reference to the AWS Dynamodb API 13 | func DynamoDB(region, endpoint string) (*dynamodb.DynamoDB, error) { 14 | cfg := &aws.Config{ 15 | Region: aws.String(region), 16 | } 17 | if endpoint != "" { 18 | cfg.Endpoint = aws.String(endpoint) 19 | } 20 | 21 | s, err := session.NewSession(cfg) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return dynamodb.New(s), nil 26 | } 27 | 28 | // Firehose constructs a new reference to the AWS Firehose API 29 | func Firehose(region string) (*firehose.Firehose, error) { 30 | cfg := &aws.Config{ 31 | Region: aws.String(region), 32 | } 33 | 34 | s, err := session.NewSession(cfg) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return firehose.New(s), nil 39 | } 40 | 41 | func wip() { 42 | f, _ := Firehose("as") 43 | f.CreateDeliveryStreamWithContext(context.Background(), &firehose.CreateDeliveryStreamInput{ 44 | DeliveryStreamName: aws.String("name"), 45 | ExtendedS3DestinationConfiguration: &firehose.ExtendedS3DestinationConfiguration{ 46 | BucketARN: aws.String("arn"), 47 | BufferingHints: &firehose.BufferingHints{ 48 | IntervalInSeconds: aws.Int64(50), 49 | SizeInMBs: aws.Int64(20), 50 | }, 51 | CloudWatchLoggingOptions: &firehose.CloudWatchLoggingOptions{ 52 | Enabled: aws.Bool(true), 53 | LogGroupName: aws.String("group-name"), 54 | LogStreamName: aws.String("log-stream-name"), 55 | }, 56 | CompressionFormat: aws.String("UNCOMPRESSED"), 57 | EncryptionConfiguration: &firehose.EncryptionConfiguration{ 58 | KMSEncryptionConfig: &firehose.KMSEncryptionConfig{ 59 | AWSKMSKeyARN: aws.String("123"), 60 | }, 61 | NoEncryptionConfig: aws.String("???"), 62 | }, 63 | Prefix: aws.String("prefix"), 64 | ProcessingConfiguration: &firehose.ProcessingConfiguration{ 65 | Enabled: aws.Bool(true), 66 | Processors: []*firehose.Processor{}, 67 | }, 68 | RoleARN: aws.String("role-arn"), 69 | S3BackupConfiguration: &firehose.S3DestinationConfiguration{}, 70 | }, 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /awscloud/clients_test.go: -------------------------------------------------------------------------------- 1 | package awscloud_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/altairsix/eventsource/awscloud" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDynamoDB(t *testing.T) { 11 | region := "the-region" 12 | endpoint := "http://my-endpoint" 13 | api, err := awscloud.DynamoDB(region, endpoint) 14 | assert.Nil(t, err) 15 | assert.Equal(t, endpoint, api.Endpoint) 16 | assert.Equal(t, endpoint, *api.Config.Endpoint) 17 | assert.Equal(t, region, *api.Config.Region) 18 | } 19 | 20 | func TestFirehose(t *testing.T) { 21 | region := "the-region" 22 | api, err := awscloud.Firehose(region) 23 | assert.Nil(t, err) 24 | assert.Equal(t, region, *api.Config.Region) 25 | } 26 | -------------------------------------------------------------------------------- /awscloud/compiles_test.go: -------------------------------------------------------------------------------- 1 | package awscloud_test 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /cmd/eventsource/compiles_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /cmd/eventsource/dynamodb/compiles_test.go: -------------------------------------------------------------------------------- 1 | package dynamodb_test 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /cmd/eventsource/dynamodb/create_table.go: -------------------------------------------------------------------------------- 1 | package dynamodb 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/altairsix/eventsource/awscloud" 9 | "github.com/altairsix/eventsource/dynamodbstore" 10 | "github.com/aws/aws-sdk-go/aws/awserr" 11 | "gopkg.in/urfave/cli.v1" 12 | ) 13 | 14 | // CreateTable holds the dynamodb create-table command 15 | var CreateTable = cli.Command{ 16 | Name: "create-table", 17 | Usage: "creates the specified dynamodb table", 18 | Flags: []cli.Flag{ 19 | flagName, 20 | cli.Int64Flag{ 21 | Name: "wcap", 22 | Usage: "write capacity", 23 | Value: 5, 24 | Destination: &opts.DynamoDB.WriteCapacity, 25 | }, 26 | cli.Int64Flag{ 27 | Name: "rcap", 28 | Usage: "read capacity", 29 | Value: 5, 30 | Destination: &opts.DynamoDB.ReadCapacity, 31 | }, 32 | cli.IntFlag{ 33 | Name: "n", 34 | Usage: "the number of events to store per partition", 35 | Value: 100, 36 | Destination: &opts.EventsPerItem, 37 | }, 38 | flagRegion, 39 | flagEndpoint, 40 | flagDryrun, 41 | }, 42 | Action: createTableAction, 43 | } 44 | 45 | func createTableAction(_ *cli.Context) error { 46 | w := os.Stdout 47 | 48 | api, err := awscloud.DynamoDB(opts.AWS.Region, opts.DynamoDB.Endpoint) 49 | if err != nil { 50 | log.Fatalln(err) 51 | } 52 | 53 | fmt.Fprintf(w, "Creating table, %v.\n", opts.DynamoDB.TableName) 54 | input := dynamodbstore.MakeCreateTableInput( 55 | opts.DynamoDB.TableName, 56 | opts.DynamoDB.ReadCapacity, 57 | opts.DynamoDB.WriteCapacity, 58 | dynamodbstore.WithRegion(opts.AWS.Region), 59 | dynamodbstore.WithEventPerItem(opts.EventsPerItem), 60 | ) 61 | _, err = api.CreateTable(input) 62 | if err != nil { 63 | if v, ok := err.(awserr.Error); ok { 64 | if v.Code() == awsResourceInUse { 65 | fmt.Fprintf(w, "Table, %v, already exists or is being deleted.\n", opts.DynamoDB.TableName) 66 | return nil 67 | } 68 | } 69 | log.Fatalln(err) 70 | } 71 | 72 | fmt.Fprintf(w, "Successfully created table, %v.\n", opts.DynamoDB.TableName) 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /cmd/eventsource/dynamodb/delete_table.go: -------------------------------------------------------------------------------- 1 | package dynamodb 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/altairsix/eventsource/awscloud" 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/awserr" 11 | "github.com/aws/aws-sdk-go/service/dynamodb" 12 | "gopkg.in/urfave/cli.v1" 13 | ) 14 | 15 | // DeleteTable holds the dynamodb delete-table command 16 | var DeleteTable = cli.Command{ 17 | Name: "delete-table", 18 | Usage: "deletes the specified dynamodb table", 19 | Flags: []cli.Flag{ 20 | flagName, 21 | flagRegion, 22 | flagEndpoint, 23 | flagDryrun, 24 | }, 25 | Action: deleteTableAction, 26 | } 27 | 28 | func deleteTableAction(_ *cli.Context) error { 29 | w := os.Stdout 30 | 31 | api, err := awscloud.DynamoDB(opts.AWS.Region, opts.DynamoDB.Endpoint) 32 | if err != nil { 33 | log.Fatalln(err) 34 | } 35 | 36 | fmt.Fprintf(w, "Deleting table, %v.\n", opts.DynamoDB.TableName) 37 | _, err = api.DeleteTable(&dynamodb.DeleteTableInput{ 38 | TableName: aws.String(opts.DynamoDB.TableName), 39 | }) 40 | if err != nil { 41 | if v, ok := err.(awserr.Error); ok { 42 | if v.Code() == awsResourceNotFound { 43 | fmt.Fprintf(w, "Unable to delete table, %v. Table not found.\n", opts.DynamoDB.TableName) 44 | return nil 45 | } 46 | } 47 | log.Fatalln(err) 48 | } 49 | 50 | fmt.Fprintf(w, "Successfully deleted table, %v.\n", opts.DynamoDB.TableName) 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /cmd/eventsource/dynamodb/options.go: -------------------------------------------------------------------------------- 1 | package dynamodb 2 | 3 | import ( 4 | "github.com/altairsix/eventsource/dynamodbstore" 5 | "gopkg.in/urfave/cli.v1" 6 | ) 7 | 8 | const ( 9 | awsResourceInUse = "ResourceInUseException" 10 | awsResourceNotFound = "ResourceNotFoundException" 11 | ) 12 | 13 | type options struct { 14 | EventsPerItem int 15 | Dryrun bool 16 | AWS struct { 17 | Region string 18 | } 19 | DynamoDB struct { 20 | Endpoint string 21 | TableName string 22 | ReadCapacity int64 23 | WriteCapacity int64 24 | PartitionSize int 25 | } 26 | } 27 | 28 | var opts = options{} 29 | 30 | var ( 31 | flagName = cli.StringFlag{ 32 | Name: "name", 33 | Usage: "name of the table", 34 | Destination: &opts.DynamoDB.TableName, 35 | } 36 | flagRegion = cli.StringFlag{ 37 | Name: "region", 38 | Usage: "AWS region to place table in", 39 | Value: dynamodbstore.DefaultRegion, 40 | EnvVar: "AWS_DEFAULT_REGION", 41 | Destination: &opts.AWS.Region, 42 | } 43 | flagEndpoint = cli.StringFlag{ 44 | Name: "endpoint", 45 | Usage: "specify the DynamoDB endpoint; useful for local testing", 46 | EnvVar: "DYNAMODB_ENDPOINT", 47 | Destination: &opts.DynamoDB.Endpoint, 48 | } 49 | flagDryrun = cli.BoolFlag{ 50 | Name: "dryrun", 51 | Usage: "perform the checks, but don't modify underlying infrastructure", 52 | Destination: &opts.Dryrun, 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /cmd/eventsource/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/altairsix/eventsource/cmd/eventsource/dynamodb" 7 | "github.com/altairsix/eventsource/cmd/eventsource/singleton" 8 | "gopkg.in/urfave/cli.v1" 9 | ) 10 | 11 | func main() { 12 | app := cli.NewApp() 13 | app.Version = "0.1.0-SNAPSHOT" 14 | app.Usage = "Utilities for managing event source" 15 | app.Commands = []cli.Command{ 16 | { 17 | Name: "dynamodb", 18 | Usage: "manages DynamoDB backed event source", 19 | Subcommands: []cli.Command{ 20 | dynamodb.CreateTable, 21 | dynamodb.DeleteTable, 22 | }, 23 | }, 24 | { 25 | Name: "singleton", 26 | Usage: "manages the DynamoDB table for the singleton feature", 27 | Subcommands: []cli.Command{ 28 | singleton.CreateTable, 29 | singleton.DeleteTable, 30 | }, 31 | }, 32 | } 33 | app.Run(os.Args) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/eventsource/singleton/compiles_test.go: -------------------------------------------------------------------------------- 1 | package singleton_test 2 | 3 | import "testing" 4 | 5 | func TestCompiles(t *testing.T) { 6 | } 7 | -------------------------------------------------------------------------------- /cmd/eventsource/singleton/create_table.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "os" 8 | 9 | "github.com/altairsix/eventsource/awscloud" 10 | "github.com/altairsix/eventsource/dynamodbstore" 11 | "github.com/aws/aws-sdk-go/aws/awserr" 12 | "gopkg.in/urfave/cli.v1" 13 | ) 14 | 15 | // CreateTable holds the singleton create-table command 16 | var CreateTable = cli.Command{ 17 | Name: "create-table", 18 | Usage: "creates the specified dynamodb table", 19 | Flags: []cli.Flag{ 20 | flagName, 21 | cli.Int64Flag{ 22 | Name: "wcap", 23 | Usage: "write capacity", 24 | Value: 5, 25 | Destination: &opts.DynamoDB.WriteCapacity, 26 | }, 27 | cli.Int64Flag{ 28 | Name: "rcap", 29 | Usage: "read capacity", 30 | Value: 5, 31 | Destination: &opts.DynamoDB.ReadCapacity, 32 | }, 33 | cli.IntFlag{ 34 | Name: "n", 35 | Usage: "the number of events to store per partition", 36 | Value: 100, 37 | Destination: &opts.EventsPerItem, 38 | }, 39 | flagRegion, 40 | flagEndpoint, 41 | flagDryrun, 42 | }, 43 | Action: createTableAction, 44 | } 45 | 46 | func createTableAction(_ *cli.Context) error { 47 | w := os.Stdout 48 | 49 | api, err := awscloud.DynamoDB(opts.AWS.Region, opts.DynamoDB.Endpoint) 50 | if err != nil { 51 | log.Fatalln(err) 52 | } 53 | 54 | fmt.Fprintf(w, "Creating table, %v.\n", opts.DynamoDB.TableName) 55 | input := dynamodbstore.MakeCreateTableInput( 56 | opts.DynamoDB.TableName, 57 | opts.DynamoDB.ReadCapacity, 58 | opts.DynamoDB.WriteCapacity, 59 | dynamodbstore.WithRegion(opts.AWS.Region), 60 | dynamodbstore.WithEventPerItem(opts.EventsPerItem), 61 | ) 62 | _, err = api.CreateTable(input) 63 | if err != nil { 64 | if v, ok := err.(awserr.Error); ok { 65 | if v.Code() == awsResourceInUse { 66 | fmt.Fprintf(w, "Table, %v, already exists or is being deleted.\n", opts.DynamoDB.TableName) 67 | return nil 68 | } 69 | } 70 | log.Fatalln(err) 71 | } 72 | 73 | fmt.Fprintf(w, "Successfully created table, %v.\n", opts.DynamoDB.TableName) 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /cmd/eventsource/singleton/delete_table.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/altairsix/eventsource/awscloud" 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/awserr" 11 | "github.com/aws/aws-sdk-go/service/dynamodb" 12 | "gopkg.in/urfave/cli.v1" 13 | ) 14 | 15 | // DeleteTable holds the singleton delete-table command 16 | // DeleteTable holds the singleton delete-table command 17 | var DeleteTable = cli.Command{ 18 | Name: "delete-table", 19 | Usage: "deletes the specified dynamodb table", 20 | Flags: []cli.Flag{ 21 | flagName, 22 | flagRegion, 23 | flagEndpoint, 24 | flagDryrun, 25 | }, 26 | Action: deleteTableAction, 27 | } 28 | 29 | func deleteTableAction(_ *cli.Context) error { 30 | w := os.Stdout 31 | 32 | api, err := awscloud.DynamoDB(opts.AWS.Region, opts.DynamoDB.Endpoint) 33 | if err != nil { 34 | log.Fatalln(err) 35 | } 36 | 37 | fmt.Fprintf(w, "Deleting table, %v.\n", opts.DynamoDB.TableName) 38 | _, err = api.DeleteTable(&dynamodb.DeleteTableInput{ 39 | TableName: aws.String(opts.DynamoDB.TableName), 40 | }) 41 | if err != nil { 42 | if v, ok := err.(awserr.Error); ok { 43 | if v.Code() == awsResourceNotFound { 44 | fmt.Fprintf(w, "Unable to delete table, %v. Table not found.\n", opts.DynamoDB.TableName) 45 | return nil 46 | } 47 | } 48 | log.Fatalln(err) 49 | } 50 | 51 | fmt.Fprintf(w, "Successfully deleted table, %v.\n", opts.DynamoDB.TableName) 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/eventsource/singleton/options.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "github.com/altairsix/eventsource/dynamodbstore" 5 | "gopkg.in/urfave/cli.v1" 6 | ) 7 | 8 | const ( 9 | awsResourceInUse = "ResourceInUseException" 10 | awsResourceNotFound = "ResourceNotFoundException" 11 | ) 12 | 13 | type options struct { 14 | EventsPerItem int 15 | Dryrun bool 16 | AWS struct { 17 | Region string 18 | } 19 | DynamoDB struct { 20 | Endpoint string 21 | TableName string 22 | ReadCapacity int64 23 | WriteCapacity int64 24 | PartitionSize int 25 | } 26 | } 27 | 28 | var opts = options{} 29 | 30 | var ( 31 | flagName = cli.StringFlag{ 32 | Name: "name", 33 | Usage: "name of the table", 34 | Destination: &opts.DynamoDB.TableName, 35 | } 36 | flagRegion = cli.StringFlag{ 37 | Name: "region", 38 | Usage: "AWS region to place table in", 39 | Value: dynamodbstore.DefaultRegion, 40 | EnvVar: "AWS_DEFAULT_REGION", 41 | Destination: &opts.AWS.Region, 42 | } 43 | flagEndpoint = cli.StringFlag{ 44 | Name: "endpoint", 45 | Usage: "specify the DynamoDB endpoint; useful for local testing", 46 | EnvVar: "DYNAMODB_ENDPOINT", 47 | Destination: &opts.DynamoDB.Endpoint, 48 | } 49 | flagDryrun = cli.BoolFlag{ 50 | Name: "dryrun", 51 | Usage: "perform the checks, but don't modify underlying infrastructure", 52 | Destination: &opts.Dryrun, 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import "context" 4 | 5 | // Command encapsulates the data to mutate an aggregate 6 | type Command interface { 7 | // AggregateID represents the id of the aggregate to apply to 8 | AggregateID() string 9 | } 10 | 11 | // CommandModel provides an embeddable struct that implements Command 12 | type CommandModel struct { 13 | // ID contains the aggregate id 14 | ID string 15 | } 16 | 17 | // AggregateID implements the Command interface; returns the aggregate id 18 | func (m CommandModel) AggregateID() string { 19 | return m.ID 20 | } 21 | 22 | // CommandHandler consumes a command and emits Events 23 | type CommandHandler interface { 24 | // Apply applies a command to an aggregate to generate a new set of events 25 | Apply(ctx context.Context, command Command) ([]Event, error) 26 | } 27 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | package eventsource_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/altairsix/eventsource" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCommandModel_AggregateID(t *testing.T) { 11 | m := eventsource.CommandModel{ID: "abc"} 12 | assert.Equal(t, m.ID, m.AggregateID()) 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | dynamodb: 4 | image: savaki/dynamodb:latest 5 | ports: 6 | - "8000:8000" 7 | 8 | mysql: 9 | image: mysql 10 | ports: 11 | - "3306:3306" 12 | environment: 13 | - MYSQL_USER=altairsix 14 | - MYSQL_PASSWORD=password 15 | - MYSQL_ROOT_PASSWORD=password 16 | - MYSQL_DATABASE=altairsix 17 | 18 | postgres: 19 | image: postgres 20 | ports: 21 | - "5432:5432" 22 | environment: 23 | - POSTGRES_USER=altairsix 24 | - POSTGRES_PASSWORD=password 25 | - POSTGRES_DB=altairsix 26 | 27 | -------------------------------------------------------------------------------- /dynamodbstore/changes.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/altairsix/eventsource" 9 | apex "github.com/apex/go-apex/dynamo" 10 | ) 11 | 12 | // Changes returns an ordered list of changes from the *dynamodbstore.Record; will never return nil 13 | func Changes(record *apex.Record) ([]eventsource.Record, error) { 14 | keys := map[string]struct{}{} 15 | 16 | // determine which keys are new 17 | 18 | if record != nil && record.Dynamodb != nil { 19 | if record.Dynamodb.NewImage != nil { 20 | for k := range record.Dynamodb.NewImage { 21 | if isKey(k) { 22 | keys[k] = struct{}{} 23 | } 24 | } 25 | } 26 | 27 | if record.Dynamodb.OldImage != nil { 28 | for k := range record.Dynamodb.OldImage { 29 | if isKey(k) { 30 | delete(keys, k) 31 | } 32 | } 33 | } 34 | } 35 | 36 | // using those keys, construct a sorted list of items 37 | 38 | items := make([]eventsource.Record, 0, len(keys)) 39 | for key := range keys { 40 | version, err := versionFromKey(key) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | data := record.Dynamodb.NewImage[key].B 46 | 47 | items = append(items, eventsource.Record{ 48 | Version: version, 49 | Data: data, 50 | }) 51 | } 52 | 53 | sort.Slice(items, func(i, j int) bool { 54 | return items[i].Version < items[j].Version 55 | }) 56 | 57 | return items, nil 58 | } 59 | 60 | var ( 61 | errInvalidEventSource = errors.New("invalid event source arn") 62 | ) 63 | 64 | // TableName extracts a table name from a dynamodb event source arn 65 | // arn:aws:dynamodb:us-west-2:528688496454:table/table-local-orgs/stream/2017-03-14T04:49:34.930 66 | func TableName(eventSource string) (string, error) { 67 | segments := strings.Split(eventSource, "/") 68 | if len(segments) < 2 { 69 | return "", errInvalidEventSource 70 | } 71 | 72 | return segments[1], nil 73 | } 74 | -------------------------------------------------------------------------------- /dynamodbstore/changes_test.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/altairsix/eventsource" 7 | "github.com/altairsix/eventsource/dynamodbstore" 8 | apex "github.com/apex/go-apex/dynamo" 9 | "github.com/aws/aws-sdk-go/service/dynamodb" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestRawEvents(t *testing.T) { 14 | testCases := map[string]struct { 15 | Record *apex.Record 16 | Expected []eventsource.Record 17 | }{ 18 | "simple": { 19 | Record: &apex.Record{ 20 | Dynamodb: &apex.StreamRecord{ 21 | NewImage: map[string]*dynamodb.AttributeValue{ 22 | "_1": {B: []byte("a")}, 23 | "_2": {B: []byte("b")}, 24 | "_3": {B: []byte("c")}, 25 | }, 26 | OldImage: map[string]*dynamodb.AttributeValue{ 27 | "_1": {B: []byte("a")}, 28 | }, 29 | }, 30 | }, 31 | Expected: []eventsource.Record{ 32 | { 33 | Version: 2, 34 | Data: []byte("b"), 35 | }, 36 | { 37 | Version: 3, 38 | Data: []byte("c"), 39 | }, 40 | }, 41 | }, 42 | } 43 | 44 | for label, tc := range testCases { 45 | t.Run(label, func(t *testing.T) { 46 | records, err := dynamodbstore.Changes(tc.Record) 47 | assert.Nil(t, err) 48 | assert.Len(t, records, 2) 49 | assert.Equal(t, tc.Expected, records) 50 | }) 51 | } 52 | } 53 | 54 | func TestTableName(t *testing.T) { 55 | tableName, err := dynamodbstore.TableName("arn:aws:dynamodb:us-west-2:528688496454:table/table-local-orgs/stream/2017-03-14T04:49:34.930") 56 | assert.Nil(t, err) 57 | assert.Equal(t, "table-local-orgs", tableName) 58 | 59 | _, err = dynamodbstore.TableName("bogus") 60 | assert.NotNil(t, err) 61 | } 62 | -------------------------------------------------------------------------------- /dynamodbstore/error.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | const ( 4 | awsConditionalCheckFailed = "ConditionalCheckFailedException" 5 | ) 6 | -------------------------------------------------------------------------------- /dynamodbstore/infra.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | ) 7 | 8 | // MakeCreateTableInput is a utility tool to write the default table definition for creating the aws tables 9 | func MakeCreateTableInput(tableName string, readCapacity, writeCapacity int64, opts ...Option) *dynamodb.CreateTableInput { 10 | store := &Store{ 11 | region: DefaultRegion, 12 | tableName: tableName, 13 | hashKey: HashKey, 14 | rangeKey: RangeKey, 15 | } 16 | 17 | for _, opt := range opts { 18 | opt(store) 19 | } 20 | 21 | input := &dynamodb.CreateTableInput{ 22 | TableName: aws.String(tableName), 23 | AttributeDefinitions: []*dynamodb.AttributeDefinition{ 24 | { 25 | AttributeName: aws.String(store.hashKey), 26 | AttributeType: aws.String("S"), 27 | }, 28 | { 29 | AttributeName: aws.String(store.rangeKey), 30 | AttributeType: aws.String("N"), 31 | }, 32 | }, 33 | KeySchema: []*dynamodb.KeySchemaElement{ 34 | { 35 | AttributeName: aws.String(store.hashKey), 36 | KeyType: aws.String("HASH"), 37 | }, 38 | { 39 | AttributeName: aws.String(store.rangeKey), 40 | KeyType: aws.String("RANGE"), 41 | }, 42 | }, 43 | ProvisionedThroughput: &dynamodb.ProvisionedThroughput{ 44 | ReadCapacityUnits: aws.Int64(readCapacity), 45 | WriteCapacityUnits: aws.Int64(writeCapacity), 46 | }, 47 | StreamSpecification: &dynamodb.StreamSpecification{ 48 | StreamEnabled: aws.Bool(true), 49 | StreamViewType: aws.String("NEW_AND_OLD_IMAGES"), 50 | }, 51 | } 52 | 53 | return input 54 | } 55 | -------------------------------------------------------------------------------- /dynamodbstore/infra_test.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | "github.com/altairsix/eventsource/awscloud" 11 | "github.com/altairsix/eventsource/dynamodbstore" 12 | "github.com/aws/aws-sdk-go/aws" 13 | "github.com/aws/aws-sdk-go/service/dynamodb" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func TestMakeCreateTableInput(t *testing.T) { 18 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 19 | if endpoint == "" { 20 | t.SkipNow() 21 | return 22 | } 23 | 24 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 25 | assert.Nil(t, err) 26 | 27 | t.Run("default", func(t *testing.T) { 28 | tableName := "default-" + strconv.FormatInt(time.Now().UnixNano(), 36) 29 | input := dynamodbstore.MakeCreateTableInput(tableName, 20, 30) 30 | 31 | _, err := api.CreateTable(input) 32 | assert.Nil(t, err) 33 | 34 | _, err = api.DeleteTable(&dynamodb.DeleteTableInput{ 35 | TableName: aws.String(tableName), 36 | }) 37 | assert.Nil(t, err) 38 | }) 39 | 40 | t.Run("kitchen-sink", func(t *testing.T) { 41 | rcap := int64(35) 42 | wcap := int64(25) 43 | 44 | tableName := "kitchen-sink-" + strconv.FormatInt(time.Now().UnixNano(), 36) 45 | input := dynamodbstore.MakeCreateTableInput(tableName, rcap, wcap, 46 | dynamodbstore.WithRegion("us-west-2"), 47 | dynamodbstore.WithEventPerItem(200), 48 | dynamodbstore.WithDynamoDB(api), 49 | dynamodbstore.WithDebug(ioutil.Discard), 50 | ) 51 | 52 | _, err := api.CreateTable(input) 53 | assert.Nil(t, err) 54 | 55 | out, err := api.DescribeTable(&dynamodb.DescribeTableInput{ 56 | TableName: aws.String(tableName), 57 | }) 58 | assert.Nil(t, err) 59 | assert.Equal(t, rcap, *out.Table.ProvisionedThroughput.ReadCapacityUnits) 60 | assert.Equal(t, wcap, *out.Table.ProvisionedThroughput.WriteCapacityUnits) 61 | 62 | _, err = api.DeleteTable(&dynamodb.DeleteTableInput{ 63 | TableName: aws.String(tableName), 64 | }) 65 | assert.Nil(t, err) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /dynamodbstore/key.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | // prefix prefixes the event keys in the dynamodb item 10 | prefix = "_" 11 | ) 12 | 13 | func isKey(key string) bool { 14 | return strings.HasPrefix(key, prefix) 15 | } 16 | 17 | func makeKey(version int) string { 18 | return prefix + strconv.Itoa(version) 19 | } 20 | 21 | func versionFromKey(key string) (int, error) { 22 | if !strings.HasPrefix(key, prefix) { 23 | return 0, errInvalidKey 24 | } 25 | 26 | version, err := strconv.Atoi(key[len(prefix):]) 27 | if err != nil { 28 | return 0, errInvalidKey 29 | } 30 | 31 | return version, nil 32 | } 33 | -------------------------------------------------------------------------------- /dynamodbstore/key_test.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestKey(t *testing.T) { 10 | version := 1 11 | key := makeKey(version) 12 | assert.True(t, isKey(key)) 13 | 14 | found, err := versionFromKey(key) 15 | assert.Nil(t, err) 16 | assert.Equal(t, version, found) 17 | } 18 | 19 | func TestVersionFromKey(t *testing.T) { 20 | testCases := map[string]struct { 21 | Key string 22 | Version int 23 | HasError bool 24 | }{ 25 | "simple": { 26 | Key: "_1", 27 | Version: 1, 28 | }, 29 | "invalid-prefix": { 30 | Key: "1", 31 | HasError: true, 32 | }, 33 | "invalid-version": { 34 | Key: "_a", 35 | HasError: true, 36 | }, 37 | } 38 | 39 | for label, tc := range testCases { 40 | t.Run(label, func(t *testing.T) { 41 | version, err := versionFromKey(tc.Key) 42 | if tc.HasError { 43 | assert.Equal(t, errInvalidKey, err) 44 | } else { 45 | assert.Nil(t, err) 46 | assert.Equal(t, tc.Version, version) 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /dynamodbstore/option.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/aws/aws-sdk-go/service/dynamodb" 7 | ) 8 | 9 | // Option represents a functional configuration of *Store 10 | type Option func(*Store) 11 | 12 | // WithRegion specifies the AWS Region to connect to 13 | func WithRegion(region string) Option { 14 | return func(s *Store) { 15 | s.region = region 16 | } 17 | } 18 | 19 | // WithEventPerItem allows you to specify the number of events to be stored per dynamodb record; defaults to 1 20 | func WithEventPerItem(eventsPerItem int) Option { 21 | return func(s *Store) { 22 | s.eventsPerItem = eventsPerItem 23 | } 24 | } 25 | 26 | // WithDynamoDB allows the caller to specify a pre-configured reference to DynamoDB 27 | func WithDynamoDB(api *dynamodb.DynamoDB) Option { 28 | return func(s *Store) { 29 | s.api = api 30 | } 31 | } 32 | 33 | // WithDebug provides additional debugging information 34 | func WithDebug(w io.Writer) Option { 35 | return func(s *Store) { 36 | s.debug = true 37 | s.writer = w 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /dynamodbstore/store.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "reflect" 10 | "sort" 11 | "strconv" 12 | 13 | "github.com/altairsix/eventsource" 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/awserr" 16 | "github.com/aws/aws-sdk-go/aws/session" 17 | "github.com/aws/aws-sdk-go/service/dynamodb" 18 | "github.com/pkg/errors" 19 | ) 20 | 21 | const ( 22 | // DefaultRegion is the region the dynamodb table is located int 23 | DefaultRegion = "us-east-1" 24 | 25 | // HashKey is the dynamodb hash key for the table; holds the aggregateID 26 | HashKey = "key" 27 | 28 | // RangeKey is the dynamodb range key for the table. Single each dynamodb record 29 | // stores multiple events, you can think of the partition as the page number and 30 | // the number of event per record as the page size 31 | RangeKey = "partition" 32 | ) 33 | 34 | var ( 35 | errInvalidKey = errors.New("invalid event key") 36 | errNoRecords = errors.New("no records to save") 37 | errDuplicateVersion = errors.New("version numbers must be unique") 38 | ) 39 | 40 | // Store represents a dynamodb backed eventsource.Store 41 | type Store struct { 42 | region string 43 | tableName string 44 | hashKey string 45 | rangeKey string 46 | api *dynamodb.DynamoDB 47 | eventsPerItem int 48 | debug bool 49 | writer io.Writer 50 | } 51 | 52 | // checkIdempotent will see if the specified records exist 53 | func (s *Store) checkIdempotent(ctx context.Context, aggregateID string, records ...eventsource.Record) error { 54 | if len(records) == 0 { 55 | return nil 56 | } 57 | 58 | version := records[len(records)-1].Version 59 | history, err := s.Load(ctx, aggregateID, 0, version) 60 | if err != nil { 61 | return err 62 | } 63 | if len(history) < len(records) { 64 | return errors.New(awsConditionalCheckFailed) 65 | } 66 | 67 | recent := history[len(history)-len(records):] 68 | if !reflect.DeepEqual(recent, eventsource.History(records)) { 69 | return errors.New(awsConditionalCheckFailed) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // Save implements the eventsource.Store interface 76 | func (s *Store) Save(ctx context.Context, aggregateID string, records ...eventsource.Record) error { 77 | if len(records) == 0 { 78 | return nil 79 | } 80 | 81 | input, err := makeUpdateItemInput(s.tableName, s.hashKey, s.rangeKey, s.eventsPerItem, aggregateID, records...) 82 | if err != nil { 83 | return err 84 | } 85 | 86 | if s.debug { 87 | encoder := json.NewEncoder(s.writer) 88 | encoder.SetIndent("", " ") 89 | encoder.Encode(input) 90 | } 91 | 92 | _, err = s.api.UpdateItem(input) 93 | if err != nil { 94 | if v, ok := err.(awserr.Error); ok { 95 | if v.Code() == awsConditionalCheckFailed { 96 | return s.checkIdempotent(ctx, aggregateID, records...) 97 | } 98 | return errors.Wrapf(err, "Save failed. %v [%v]", v.Message(), v.Code()) 99 | } 100 | return err 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // Load satisfies the Store interface and retrieve events from dynamodb 107 | func (s *Store) Load(ctx context.Context, aggregateID string, fromVersion, toVersion int) (eventsource.History, error) { 108 | from := selectPartition(fromVersion, s.eventsPerItem) 109 | to := selectPartition(toVersion, s.eventsPerItem) 110 | input, err := makeQueryInput(s.tableName, s.hashKey, s.rangeKey, aggregateID, from, to) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | history := make(eventsource.History, 0, toVersion) 116 | 117 | var startKey map[string]*dynamodb.AttributeValue 118 | for { 119 | out, err := s.api.Query(input) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | if len(out.Items) == 0 { 125 | break 126 | } 127 | 128 | // events are stored within av as _t{version} = {event-type}, _d{version} = {serialized event} 129 | for _, item := range out.Items { 130 | for key, av := range item { 131 | if !isKey(key) { 132 | continue 133 | } 134 | 135 | recordVersion, err := versionFromKey(key) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | if recordVersion < fromVersion { 141 | continue 142 | } 143 | if toVersion > 0 && recordVersion > toVersion { 144 | continue 145 | } 146 | 147 | history = append(history, eventsource.Record{ 148 | Version: recordVersion, 149 | Data: av.B, 150 | }) 151 | } 152 | } 153 | 154 | startKey = out.LastEvaluatedKey 155 | if len(startKey) == 0 { 156 | break 157 | } 158 | } 159 | 160 | sort.Slice(history, func(i, j int) bool { 161 | return history[i].Version < history[j].Version 162 | }) 163 | 164 | return history, nil 165 | } 166 | 167 | // New constructs a new dynamodb backed store 168 | func New(tableName string, opts ...Option) (*Store, error) { 169 | store := &Store{ 170 | region: DefaultRegion, 171 | tableName: tableName, 172 | hashKey: HashKey, 173 | rangeKey: RangeKey, 174 | eventsPerItem: 100, 175 | } 176 | 177 | for _, opt := range opts { 178 | opt(store) 179 | } 180 | 181 | if store.api == nil { 182 | cfg := &aws.Config{Region: aws.String(store.region)} 183 | s, err := session.NewSession(cfg) 184 | if err != nil { 185 | if v, ok := err.(awserr.Error); ok { 186 | return nil, errors.Wrapf(err, "Unable to create AWS Session - %v [%v]", v.Message(), v.Code()) 187 | } 188 | return nil, err 189 | } 190 | store.api = dynamodb.New(s) 191 | } 192 | 193 | return store, nil 194 | } 195 | 196 | func validateInput(records ...eventsource.Record) error { 197 | // must save at least one record 198 | if len(records) == 0 { 199 | return errNoRecords 200 | } 201 | 202 | // version numbers may not duplicated 203 | // TODO - do version numbers have to be sequential or is increasing satisfactory? 204 | for i := len(records) - 2; i >= 0; i-- { 205 | if records[i].Version == records[i+1].Version { 206 | return errDuplicateVersion 207 | } 208 | } 209 | 210 | return nil 211 | } 212 | 213 | func makeUpdateItemInput(tableName, hashKey, rangeKey string, eventsPerItem int, aggregateID string, records ...eventsource.Record) (*dynamodb.UpdateItemInput, error) { 214 | sort.Slice(records, func(i, j int) bool { 215 | return records[i].Version < records[j].Version 216 | }) 217 | 218 | err := validateInput(records...) 219 | if err != nil { 220 | return nil, err 221 | } 222 | 223 | partitionID := selectPartition(records[0].Version, eventsPerItem) 224 | 225 | input := &dynamodb.UpdateItemInput{ 226 | TableName: aws.String(tableName), 227 | Key: map[string]*dynamodb.AttributeValue{ 228 | hashKey: {S: aws.String(aggregateID)}, 229 | rangeKey: {N: aws.String(strconv.Itoa(partitionID))}, 230 | }, 231 | ExpressionAttributeNames: map[string]*string{ 232 | "#revision": aws.String("revision"), 233 | }, 234 | ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ 235 | ":one": {N: aws.String("1")}, 236 | }, 237 | } 238 | 239 | // Add each element within the partition to the UpdateItemInput 240 | 241 | condExpr := &bytes.Buffer{} 242 | updateExpr := &bytes.Buffer{} 243 | io.WriteString(updateExpr, "ADD #revision :one SET ") 244 | 245 | for index, record := range records { 246 | // Each event is store as two entries, an event entries and an event type entry. 247 | 248 | key := makeKey(record.Version) 249 | nameRef := "#" + key 250 | valueRef := ":" + key 251 | 252 | if index > 0 { 253 | io.WriteString(condExpr, " AND ") 254 | io.WriteString(updateExpr, ", ") 255 | } 256 | fmt.Fprintf(condExpr, "attribute_not_exists(%v)", nameRef) 257 | fmt.Fprintf(updateExpr, "%v = %v", nameRef, valueRef) 258 | input.ExpressionAttributeNames[nameRef] = aws.String(key) 259 | input.ExpressionAttributeValues[valueRef] = &dynamodb.AttributeValue{B: record.Data} 260 | } 261 | 262 | input.ConditionExpression = aws.String(condExpr.String()) 263 | input.UpdateExpression = aws.String(updateExpr.String()) 264 | 265 | return input, nil 266 | } 267 | 268 | // makeQueryInput 269 | // - partition - fetch up to this partition number; 0 to fetch all partitions 270 | func makeQueryInput(tableName, hashKey, rangeKey string, aggregateID string, fromPartition, toPartition int) (*dynamodb.QueryInput, error) { 271 | input := &dynamodb.QueryInput{ 272 | TableName: aws.String(tableName), 273 | Select: aws.String("ALL_ATTRIBUTES"), 274 | ConsistentRead: aws.Bool(true), 275 | ExpressionAttributeNames: map[string]*string{ 276 | "#key": aws.String(hashKey), 277 | }, 278 | ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ 279 | ":key": {S: aws.String(aggregateID)}, 280 | }, 281 | } 282 | 283 | if toPartition == 0 { 284 | input.KeyConditionExpression = aws.String("#key = :key") 285 | 286 | } else { 287 | input.KeyConditionExpression = aws.String("#key = :key AND #partition >= :from AND #partition <= :to") 288 | input.ExpressionAttributeNames["#partition"] = aws.String(rangeKey) 289 | input.ExpressionAttributeValues[":from"] = &dynamodb.AttributeValue{N: aws.String(strconv.Itoa(fromPartition))} 290 | input.ExpressionAttributeValues[":to"] = &dynamodb.AttributeValue{N: aws.String(strconv.Itoa(toPartition))} 291 | } 292 | 293 | return input, nil 294 | } 295 | 296 | func selectPartition(version, eventsPerItem int) int { 297 | return version / eventsPerItem 298 | } 299 | -------------------------------------------------------------------------------- /dynamodbstore/store_inline_test.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestStore_CheckIdempotent(t *testing.T) { 11 | s := &Store{} 12 | err := s.checkIdempotent(context.Background(), "abc") 13 | assert.Nil(t, err, "no records provided; guaranteed idempotent!") 14 | } 15 | -------------------------------------------------------------------------------- /dynamodbstore/store_test.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/altairsix/eventsource" 9 | "github.com/altairsix/eventsource/awscloud" 10 | "github.com/altairsix/eventsource/dynamodbstore" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestStore_ImplementsStore(t *testing.T) { 15 | v, err := dynamodbstore.New("blah") 16 | assert.Nil(t, err) 17 | 18 | var store eventsource.Store = v 19 | assert.NotNil(t, store) 20 | } 21 | 22 | func TestStore_SaveEmpty(t *testing.T) { 23 | s, err := dynamodbstore.New("blah") 24 | assert.Nil(t, err) 25 | 26 | err = s.Save(context.Background(), "abc") 27 | assert.Nil(t, err, "no records saved; guaranteed to work") 28 | } 29 | 30 | func TestStore_SaveAndFetch(t *testing.T) { 31 | t.Parallel() 32 | 33 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 34 | if endpoint == "" { 35 | t.SkipNow() 36 | return 37 | } 38 | 39 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 40 | assert.Nil(t, err) 41 | 42 | TempTable(t, api, func(tableName string) { 43 | ctx := context.Background() 44 | store, err := dynamodbstore.New(tableName, 45 | dynamodbstore.WithDynamoDB(api), 46 | ) 47 | assert.Nil(t, err) 48 | 49 | aggregateID := "abc" 50 | history := eventsource.History{ 51 | { 52 | Version: 1, 53 | Data: []byte("a"), 54 | }, 55 | { 56 | Version: 2, 57 | Data: []byte("b"), 58 | }, 59 | { 60 | Version: 3, 61 | Data: []byte("c"), 62 | }, 63 | } 64 | err = store.Save(ctx, aggregateID, history...) 65 | assert.Nil(t, err) 66 | 67 | found, err := store.Load(ctx, aggregateID, 0, 0) 68 | assert.Nil(t, err) 69 | assert.Equal(t, history, found) 70 | assert.Len(t, found, len(history)) 71 | }) 72 | } 73 | 74 | func TestStore_SaveAndLoadFromVersion(t *testing.T) { 75 | t.Parallel() 76 | 77 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 78 | if endpoint == "" { 79 | t.SkipNow() 80 | return 81 | } 82 | 83 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 84 | assert.Nil(t, err) 85 | 86 | TempTable(t, api, func(tableName string) { 87 | ctx := context.Background() 88 | store, err := dynamodbstore.New(tableName, 89 | dynamodbstore.WithDynamoDB(api), 90 | ) 91 | assert.Nil(t, err) 92 | 93 | aggregateID := "abc" 94 | history := eventsource.History{ 95 | { 96 | Version: 1, 97 | Data: []byte("a"), 98 | }, 99 | { 100 | Version: 2, 101 | Data: []byte("b"), 102 | }, 103 | { 104 | Version: 3, 105 | Data: []byte("c"), 106 | }, 107 | } 108 | err = store.Save(ctx, aggregateID, history...) 109 | assert.Nil(t, err) 110 | 111 | found, err := store.Load(ctx, aggregateID, 2, 0) 112 | assert.Nil(t, err) 113 | assert.Equal(t, history[1:], found) 114 | assert.Len(t, found, len(history)-1) 115 | }) 116 | } 117 | 118 | func TestStore_SaveIdempotent(t *testing.T) { 119 | t.Parallel() 120 | 121 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 122 | if endpoint == "" { 123 | t.SkipNow() 124 | return 125 | } 126 | 127 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 128 | assert.Nil(t, err) 129 | 130 | TempTable(t, api, func(tableName string) { 131 | ctx := context.Background() 132 | store, err := dynamodbstore.New(tableName, 133 | dynamodbstore.WithDynamoDB(api), 134 | ) 135 | assert.Nil(t, err) 136 | 137 | aggregateID := "abc" 138 | history := eventsource.History{ 139 | { 140 | Version: 1, 141 | Data: []byte("a"), 142 | }, 143 | { 144 | Version: 2, 145 | Data: []byte("b"), 146 | }, 147 | { 148 | Version: 3, 149 | Data: []byte("c"), 150 | }, 151 | } 152 | // initial save 153 | err = store.Save(ctx, aggregateID, history...) 154 | assert.Nil(t, err) 155 | 156 | // When - save it again 157 | err = store.Save(ctx, aggregateID, history...) 158 | // Then - verify no errors e.g. idempotent 159 | assert.Nil(t, err) 160 | 161 | found, err := store.Load(ctx, aggregateID, 0, 0) 162 | assert.Nil(t, err) 163 | assert.Equal(t, history, found) 164 | assert.Len(t, found, len(history)) 165 | }) 166 | } 167 | 168 | func TestStore_SaveOptimisticLock(t *testing.T) { 169 | t.Parallel() 170 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 171 | if endpoint == "" { 172 | t.SkipNow() 173 | return 174 | } 175 | 176 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 177 | assert.Nil(t, err) 178 | 179 | TempTable(t, api, func(tableName string) { 180 | ctx := context.Background() 181 | store, err := dynamodbstore.New(tableName, 182 | dynamodbstore.WithDynamoDB(api), 183 | ) 184 | assert.Nil(t, err) 185 | 186 | aggregateID := "abc" 187 | initial := eventsource.History{ 188 | { 189 | Version: 1, 190 | Data: []byte("a"), 191 | }, 192 | { 193 | Version: 2, 194 | Data: []byte("b"), 195 | }, 196 | } 197 | // initial save 198 | err = store.Save(ctx, aggregateID, initial...) 199 | assert.Nil(t, err) 200 | 201 | overlap := eventsource.History{ 202 | { 203 | Version: 2, 204 | Data: []byte("c"), 205 | }, 206 | { 207 | Version: 3, 208 | Data: []byte("d"), 209 | }, 210 | } 211 | // save overlapping events; should not be allowed 212 | err = store.Save(ctx, aggregateID, overlap...) 213 | assert.NotNil(t, err) 214 | }) 215 | } 216 | 217 | func TestStore_LoadPartition(t *testing.T) { 218 | t.Parallel() 219 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 220 | if endpoint == "" { 221 | t.SkipNow() 222 | return 223 | } 224 | 225 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 226 | assert.Nil(t, err) 227 | 228 | TempTable(t, api, func(tableName string) { 229 | store, err := dynamodbstore.New(tableName, 230 | dynamodbstore.WithDynamoDB(api), 231 | dynamodbstore.WithEventPerItem(2), 232 | ) 233 | assert.Nil(t, err) 234 | 235 | aggregateID := "abc" 236 | history := eventsource.History{ 237 | { 238 | Version: 1, 239 | Data: []byte("a"), 240 | }, 241 | { 242 | Version: 2, 243 | Data: []byte("b"), 244 | }, 245 | { 246 | Version: 3, 247 | Data: []byte("c"), 248 | }, 249 | } 250 | ctx := context.Background() 251 | err = store.Save(ctx, aggregateID, history...) 252 | assert.Nil(t, err) 253 | 254 | found, err := store.Load(ctx, aggregateID, 0, 1) 255 | assert.Nil(t, err) 256 | assert.Len(t, found, 1) 257 | assert.Equal(t, history[0:1], found) 258 | }) 259 | } 260 | -------------------------------------------------------------------------------- /dynamodbstore/util_test.go: -------------------------------------------------------------------------------- 1 | package dynamodbstore_test 2 | 3 | import ( 4 | "math/rand" 5 | "strconv" 6 | "testing" 7 | "time" 8 | 9 | "github.com/altairsix/eventsource/dynamodbstore" 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/service/dynamodb" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var ( 16 | r = rand.New(rand.NewSource(time.Now().UnixNano())) 17 | ) 18 | 19 | func TempTable(t *testing.T, api *dynamodb.DynamoDB, fn func(tableName string)) { 20 | // Create a temporary table for use during this test 21 | // 22 | now := strconv.FormatInt(time.Now().UnixNano(), 36) 23 | random := strconv.FormatInt(int64(r.Int31()), 36) 24 | tableName := "tmp-" + now + "-" + random 25 | input := dynamodbstore.MakeCreateTableInput(tableName, 50, 50) 26 | _, err := api.CreateTable(input) 27 | assert.Nil(t, err) 28 | defer func() { 29 | _, err := api.DeleteTable(&dynamodb.DeleteTableInput{TableName: aws.String(tableName)}) 30 | assert.Nil(t, err) 31 | }() 32 | 33 | fn(tableName) 34 | } 35 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import "fmt" 4 | 5 | const ( 6 | //AggregateNil = "AggregateNil" 7 | //DuplicateID = "DuplicateID" 8 | //DuplicateVersion = "DuplicateVersion" 9 | //DuplicateAt = "DuplicateAt" 10 | //DuplicateType = "DuplicateType" 11 | //InvalidID = "InvalidID" 12 | //InvalidAt = "InvalidAt" 13 | //InvalidVersion = "InvalidVersion" 14 | 15 | // InvalidEncoding is returned when the Serializer cannot marshal the event 16 | ErrInvalidEncoding = "InvalidEncoding" 17 | 18 | // UnboundEventType when the Serializer cannot unmarshal the serialized event 19 | ErrUnboundEventType = "UnboundEventType" 20 | 21 | // AggregateNotFound will be returned when attempting to Load an aggregateID 22 | // that does not exist in the Store 23 | ErrAggregateNotFound = "AggregateNotFound" 24 | 25 | // UnhandledEvent occurs when the Aggregate is unable to handle an event and returns 26 | // a non-nill err 27 | ErrUnhandledEvent = "UnhandledEvent" 28 | ) 29 | 30 | // Error provides a standardized error interface for eventsource 31 | type Error interface { 32 | error 33 | 34 | // Returns the original error if one was set. Nil is returned if not set. 35 | Cause() error 36 | 37 | // Returns the short phrase depicting the classification of the error. 38 | Code() string 39 | 40 | // Returns the error details message. 41 | Message() string 42 | } 43 | 44 | type baseErr struct { 45 | cause error 46 | code string 47 | message string 48 | } 49 | 50 | func (b *baseErr) Cause() error { return b.cause } 51 | func (b *baseErr) Code() string { return b.code } 52 | func (b *baseErr) Message() string { return b.message } 53 | func (b *baseErr) Error() string { return fmt.Sprintf("[%v] %v - %v", b.code, b.message, b.cause) } 54 | func (b *baseErr) String() string { return b.Error() } 55 | 56 | // NewError generates the common error structure 57 | func NewError(err error, code, format string, args ...interface{}) error { 58 | return &baseErr{ 59 | code: code, 60 | message: fmt.Sprintf(format, args...), 61 | cause: err, 62 | } 63 | } 64 | 65 | // ErrHasCode returns true if any error in the cause chain has the specified code 66 | func ErrHasCode(err error, code string) bool { 67 | if err == nil { 68 | return false 69 | } 70 | 71 | v, ok := err.(Error) 72 | if !ok { 73 | return false 74 | } 75 | 76 | if v.Code() == code { 77 | return true 78 | } 79 | 80 | if cause := v.Cause(); cause != nil { 81 | return ErrHasCode(cause, code) 82 | } 83 | 84 | return false 85 | } 86 | 87 | // IsNotFound returns true if the issue as the aggregate was not found 88 | func IsNotFound(err error) bool { 89 | for err != nil { 90 | if err == nil { 91 | return false 92 | } 93 | 94 | v, ok := err.(Error) 95 | if !ok { 96 | return false 97 | } 98 | 99 | if v.Code() == ErrAggregateNotFound { 100 | return true 101 | } 102 | 103 | err = v.Cause() 104 | } 105 | 106 | return false 107 | } 108 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package eventsource_test 2 | 3 | import ( 4 | "io" 5 | "testing" 6 | 7 | "fmt" 8 | 9 | "github.com/altairsix/eventsource" 10 | "github.com/pkg/errors" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestNewError(t *testing.T) { 15 | err := eventsource.NewError(io.EOF, "code", "hello %v", "world") 16 | assert.NotNil(t, err) 17 | 18 | v, ok := err.(eventsource.Error) 19 | assert.True(t, ok) 20 | assert.Equal(t, io.EOF, v.Cause()) 21 | assert.Equal(t, "code", v.Code()) 22 | assert.Equal(t, "hello world", v.Message()) 23 | assert.Equal(t, "[code] hello world - EOF", v.Error()) 24 | 25 | s, ok := err.(fmt.Stringer) 26 | assert.True(t, ok) 27 | assert.Equal(t, v.Error(), s.String()) 28 | } 29 | 30 | func TestIsNotFound(t *testing.T) { 31 | testCases := map[string]struct { 32 | Err error 33 | IsNotFound bool 34 | }{ 35 | "nil": { 36 | Err: nil, 37 | IsNotFound: false, 38 | }, 39 | "eventsource.Error": { 40 | Err: eventsource.NewError(nil, eventsource.ErrAggregateNotFound, "not found"), 41 | IsNotFound: true, 42 | }, 43 | "nested eventsource.Error": { 44 | Err: eventsource.NewError( 45 | eventsource.NewError(nil, eventsource.ErrAggregateNotFound, "not found"), 46 | eventsource.ErrUnboundEventType, 47 | "not found", 48 | ), 49 | IsNotFound: true, 50 | }, 51 | } 52 | 53 | for label, tc := range testCases { 54 | t.Run(label, func(t *testing.T) { 55 | assert.Equal(t, tc.IsNotFound, eventsource.IsNotFound(tc.Err)) 56 | }) 57 | } 58 | } 59 | 60 | func TestErrHasCode(t *testing.T) { 61 | code := "code" 62 | 63 | testCases := map[string]struct { 64 | Err error 65 | ErrHasCode bool 66 | }{ 67 | "simple": { 68 | Err: eventsource.NewError(nil, code, "blah"), 69 | ErrHasCode: true, 70 | }, 71 | "nope": { 72 | Err: errors.New("blah"), 73 | ErrHasCode: false, 74 | }, 75 | "nested": { 76 | Err: eventsource.NewError(eventsource.NewError(nil, code, "blah"), "blah", "blah"), 77 | ErrHasCode: true, 78 | }, 79 | } 80 | 81 | for label, tc := range testCases { 82 | t.Run(label, func(t *testing.T) { 83 | assert.Equal(t, tc.ErrHasCode, eventsource.ErrHasCode(tc.Err, code)) 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import "time" 4 | 5 | // Event describe a change that happened to the Aggregate 6 | // 7 | // * Past tense e.g. EmailChanged 8 | // * Contains intent e.g. EmailChanged is better than EmailSet 9 | type Event interface { 10 | // AggregateID returns the id of the aggregate referenced by the event 11 | AggregateID() string 12 | 13 | // EventVersion contains the version number of this event 14 | EventVersion() int 15 | 16 | // EventAt indicates when the event occurred 17 | EventAt() time.Time 18 | } 19 | 20 | // EventTyper is an optional interface that an Event can implement that allows it to specify an event type 21 | // different than the name of the struct 22 | type EventTyper interface { 23 | // EventType returns the name of event type 24 | EventType() string 25 | } 26 | 27 | // Model provides a default implementation of an Event 28 | type Model struct { 29 | // ID contains the AggregateID 30 | ID string 31 | 32 | // Version contains the EventVersion 33 | Version int 34 | 35 | // At contains the EventAt 36 | At time.Time 37 | } 38 | 39 | // AggregateID implements the Event interface 40 | func (m Model) AggregateID() string { 41 | return m.ID 42 | } 43 | 44 | // EventVersion implements the Event interface 45 | func (m Model) EventVersion() int { 46 | return m.Version 47 | } 48 | 49 | // EventAt implements the Event interface 50 | func (m Model) EventAt() time.Time { 51 | return m.At 52 | } 53 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package eventsource_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/altairsix/eventsource" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestEvent(t *testing.T) { 12 | m := eventsource.Model{ 13 | ID: "abc", 14 | Version: 123, 15 | At: time.Now(), 16 | } 17 | 18 | assert.Equal(t, m.ID, m.AggregateID()) 19 | assert.Equal(t, m.Version, m.EventVersion()) 20 | assert.Equal(t, m.At, m.EventAt()) 21 | } 22 | -------------------------------------------------------------------------------- /mysqlstore/infra.go: -------------------------------------------------------------------------------- 1 | package mysqlstore 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | const ( 10 | // CreateSQL provides sql to create the event source table 11 | CreateSQL = ` 12 | CREATE TABLE IF NOT EXISTS ${TABLE} ( 13 | id INT PRIMARY KEY AUTO_INCREMENT, 14 | aggregate_id VARCHAR(255), 15 | data VARBINARY(4096), 16 | version INT 17 | ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8; 18 | ` 19 | 20 | // CheckIndexSQL provides sql to query db to determine whether the index exists 21 | CheckIndexSQL = ` 22 | SELECT 23 | COUNT(*) IndexIsThere 24 | FROM 25 | INFORMATION_SCHEMA.STATISTICS 26 | WHERE table_schema=DATABASE() 27 | AND table_name='${TABLE}' AND index_name='idx_${TABLE}'; 28 | ` 29 | 30 | // CreateIndexSQL provides sql to create the index 31 | CreateIndexSQL = ` 32 | CREATE UNIQUE INDEX idx_${TABLE} 33 | ON ${TABLE} (aggregate_id, version); 34 | ` 35 | ) 36 | 37 | func expand(template, tableName string) string { 38 | return strings.Replace(template, `${TABLE}`, tableName, -1) 39 | } 40 | 41 | // CreateIfNotExists creates the specified table and index(es) in the db if they do not already exist 42 | func CreateIfNotExists(db DB, tableName string) error { 43 | _, err := db.Exec(expand(CreateSQL, tableName)) 44 | if err != nil { 45 | return errors.Wrap(err, "unable to create table") 46 | } 47 | 48 | row, err := db.Query(expand(CheckIndexSQL, tableName)) 49 | if err != nil { 50 | return errors.Wrap(err, "query failed to determine if index exists") 51 | } 52 | 53 | row.Next() 54 | exists := 0 55 | err = row.Scan(&exists) 56 | if err != nil { 57 | return errors.Wrap(err, "unable to read response for whether index exists") 58 | } 59 | 60 | if exists > 0 { 61 | return nil 62 | } 63 | 64 | _, err = db.Exec(expand(CreateIndexSQL, tableName)) 65 | if err != nil { 66 | return errors.Wrap(err, "unable to create index") 67 | } 68 | 69 | return err 70 | } 71 | -------------------------------------------------------------------------------- /mysqlstore/store.go: -------------------------------------------------------------------------------- 1 | package mysqlstore 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "math" 8 | "reflect" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/altairsix/eventsource" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | const ( 17 | insertSQL = `INSERT INTO ${TABLE} (aggregate_id, data, version) VALUES (?, ?, ?)` 18 | selectSQL = `SELECT data, version FROM ${TABLE} WHERE aggregate_id = ? AND version >= ? AND version <= ? ORDER BY version ASC` 19 | readSQL = `SELECT id, aggregate_id, data, version FROM ${TABLE} WHERE id >= ? ORDER BY ID LIMIT ?` 20 | ) 21 | 22 | // DB provides a smaller surface area for the db calls used; Exec is only used by the create function 23 | type DB interface { 24 | // Exec is implemented by *sql.DB and *sql.Tx 25 | Exec(query string, args ...interface{}) (sql.Result, error) 26 | // PrepareContext is implemented by *sql.DB and *sql.Tx 27 | PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 28 | // Query is implemented by *sql.DB and *sql.Tx 29 | Query(query string, args ...interface{}) (*sql.Rows, error) 30 | } 31 | 32 | // Accessor provides a standard interface to allow the store to obtain a db connection 33 | type Accessor interface { 34 | // Open requests a new connection 35 | Open(ctx context.Context) (DB, error) 36 | 37 | // Close will be called when the store is finished with the connection 38 | Close(DB) error 39 | } 40 | 41 | // Store provides an eventsource.Store implementation backed by mysql 42 | type Store struct { 43 | tableName string 44 | accessor Accessor 45 | } 46 | 47 | func (s *Store) expand(statement string) string { 48 | return strings.Replace(statement, "${TABLE}", s.tableName, -1) 49 | } 50 | 51 | // Save the provided serialized records to the store 52 | func (s *Store) Save(ctx context.Context, aggregateID string, records ...eventsource.Record) error { 53 | if len(records) == 0 { 54 | return nil 55 | } 56 | 57 | db, err := s.accessor.Open(ctx) 58 | if err != nil { 59 | return errors.Wrap(err, "save failed; unable to connect to db") 60 | } 61 | defer s.accessor.Close(db) 62 | 63 | stmt, err := db.PrepareContext(ctx, s.expand(insertSQL)) 64 | if err != nil { 65 | return errors.Wrap(err, "unable to prepare statement") 66 | } 67 | defer stmt.Close() 68 | 69 | for _, record := range records { 70 | _, err = stmt.Exec(aggregateID, record.Data, record.Version) 71 | if err != nil { 72 | return s.isIdempotent(ctx, db, aggregateID, records...) 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func (s *Store) isIdempotent(ctx context.Context, db DB, aggregateID string, records ...eventsource.Record) error { 80 | segments := eventsource.History(records) 81 | sort.Sort(segments) 82 | 83 | fromVersion := segments[0].Version 84 | toVersion := segments[len(segments)-1].Version 85 | loaded, err := s.doLoad(ctx, db, aggregateID, fromVersion, toVersion) 86 | if err != nil { 87 | return fmt.Errorf("unable to retrieve version %v-%v for aggregate, %v", fromVersion, toVersion, aggregateID) 88 | } 89 | 90 | if !reflect.DeepEqual(segments, loaded) { 91 | return fmt.Errorf("unable to save records; conflicting records detected for aggregate, %v", aggregateID) 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // Load the history of events up to the version specified; when version is 98 | // 0, all events will be loaded 99 | func (s *Store) Load(ctx context.Context, aggregateID string, fromVersion, toVersion int) (eventsource.History, error) { 100 | db, err := s.accessor.Open(ctx) 101 | if err != nil { 102 | return nil, errors.Wrap(err, "load failed; unable to connect to db") 103 | } 104 | defer s.accessor.Close(db) 105 | 106 | return s.doLoad(ctx, db, aggregateID, fromVersion, toVersion) 107 | } 108 | 109 | func (s *Store) doLoad(ctx context.Context, db DB, aggregateID string, initialVersion, version int) (eventsource.History, error) { 110 | if version == 0 { 111 | version = math.MaxInt32 112 | } 113 | 114 | rows, err := db.Query(s.expand(selectSQL), aggregateID, initialVersion, version) 115 | if err != nil { 116 | return nil, errors.Wrap(err, "load failed; unable to query rows") 117 | } 118 | 119 | history := eventsource.History{} 120 | for rows.Next() { 121 | record := eventsource.Record{} 122 | if err := rows.Scan(&record.Data, &record.Version); err != nil { 123 | return nil, errors.Wrap(err, "load failed; unable to parse row") 124 | } 125 | history = append(history, record) 126 | } 127 | 128 | return history, nil 129 | } 130 | 131 | func (s *Store) Read(ctx context.Context, startingOffset uint64, recordCount int) ([]eventsource.StreamRecord, error) { 132 | db, err := s.accessor.Open(ctx) 133 | if err != nil { 134 | return nil, errors.Wrap(err, "load failed; unable to connect to db") 135 | } 136 | defer s.accessor.Close(db) 137 | 138 | records := make([]eventsource.StreamRecord, 0, recordCount) 139 | rows, err := db.Query(s.expand(readSQL), startingOffset, recordCount) 140 | if err != nil { 141 | return nil, errors.Wrap(err, "read failed; unable to read records from db") 142 | } 143 | defer rows.Close() 144 | 145 | for rows.Next() { 146 | record := eventsource.StreamRecord{} 147 | if err := rows.Scan(&record.Offset, &record.AggregateID, &record.Data, &record.Version); err != nil { 148 | return nil, errors.Wrapf(err, "failed to scan stream record from db") 149 | } 150 | records = append(records, record) 151 | } 152 | 153 | return records, nil 154 | } 155 | 156 | // New returns a new postgres backed eventsource.Store 157 | func New(tableName string, accessor Accessor) (*Store, error) { 158 | store := &Store{ 159 | tableName: tableName, 160 | accessor: accessor, 161 | } 162 | 163 | return store, nil 164 | } 165 | -------------------------------------------------------------------------------- /mysqlstore/store_test.go: -------------------------------------------------------------------------------- 1 | package mysqlstore_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/altairsix/eventsource" 8 | "github.com/altairsix/eventsource/mysqlstore" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type Accessor struct { 13 | db mysqlstore.DB 14 | } 15 | 16 | func (a Accessor) Open(ctx context.Context) (mysqlstore.DB, error) { 17 | return a.db, nil 18 | } 19 | 20 | func (a Accessor) Close(db mysqlstore.DB) error { 21 | return nil 22 | } 23 | 24 | func TestStore_ImplementsStore(t *testing.T) { 25 | v, err := mysqlstore.New("blah", nil) 26 | assert.Nil(t, err) 27 | 28 | var store eventsource.Store = v 29 | assert.NotNil(t, store) 30 | } 31 | 32 | func TestStore_ImplementsStreamReader(t *testing.T) { 33 | v, err := mysqlstore.New("blah", nil) 34 | assert.Nil(t, err) 35 | 36 | var reader eventsource.StreamReader = v 37 | assert.NotNil(t, reader) 38 | } 39 | 40 | func TestStore_SaveEmpty(t *testing.T) { 41 | s, err := mysqlstore.New("blah", nil) 42 | assert.Nil(t, err) 43 | 44 | err = s.Save(context.Background(), "abc") 45 | assert.Nil(t, err, "no records saved; guaranteed to work") 46 | } 47 | 48 | func TestStore_SaveAndFetch(t *testing.T) { 49 | WithRollback(t, func(db DB, tableName string) { 50 | ctx := context.Background() 51 | store, err := mysqlstore.New(tableName, Accessor{db: db}) 52 | assert.Nil(t, err) 53 | 54 | aggregateID := "abc" 55 | history := eventsource.History{ 56 | { 57 | Version: 1, 58 | Data: []byte("a"), 59 | }, 60 | { 61 | Version: 2, 62 | Data: []byte("b"), 63 | }, 64 | { 65 | Version: 3, 66 | Data: []byte("c"), 67 | }, 68 | } 69 | err = store.Save(ctx, aggregateID, history...) 70 | assert.Nil(t, err) 71 | 72 | found, err := store.Load(ctx, aggregateID, 0, 0) 73 | assert.Nil(t, err) 74 | assert.Equal(t, history, found) 75 | assert.Len(t, found, len(history)) 76 | }) 77 | } 78 | 79 | func TestStore_SaveAndRead(t *testing.T) { 80 | WithRollback(t, func(db DB, tableName string) { 81 | ctx := context.Background() 82 | store, err := mysqlstore.New(tableName, Accessor{db: db}) 83 | assert.Nil(t, err) 84 | 85 | aggregateID := "abc" 86 | history := eventsource.History{ 87 | { 88 | Version: 1, 89 | Data: []byte("a"), 90 | }, 91 | { 92 | Version: 2, 93 | Data: []byte("b"), 94 | }, 95 | { 96 | Version: 3, 97 | Data: []byte("c"), 98 | }, 99 | } 100 | err = store.Save(ctx, aggregateID, history...) 101 | assert.Nil(t, err) 102 | 103 | found, err := store.Read(ctx, 0, len(history)) 104 | assert.Nil(t, err) 105 | assert.Len(t, found, len(history)) 106 | 107 | for _, item := range found { 108 | assert.NotZero(t, item.Offset) 109 | assert.NotZero(t, item.AggregateID) 110 | assert.NotZero(t, item.Data) 111 | assert.NotZero(t, item.Version) 112 | } 113 | }) 114 | } 115 | 116 | func TestStore_SaveIdempotent(t *testing.T) { 117 | WithRollback(t, func(db DB, tableName string) { 118 | ctx := context.Background() 119 | store, err := mysqlstore.New(tableName, Accessor{db: db}) 120 | assert.Nil(t, err) 121 | 122 | aggregateID := "abc" 123 | history := eventsource.History{ 124 | { 125 | Version: 1, 126 | Data: []byte("a"), 127 | }, 128 | { 129 | Version: 2, 130 | Data: []byte("b"), 131 | }, 132 | { 133 | Version: 3, 134 | Data: []byte("c"), 135 | }, 136 | } 137 | // initial save 138 | err = store.Save(ctx, aggregateID, history...) 139 | assert.Nil(t, err) 140 | 141 | // When - save it again 142 | err = store.Save(ctx, aggregateID, history...) 143 | // Then - verify no errors e.g. idempotent 144 | assert.Nil(t, err) 145 | 146 | found, err := store.Load(ctx, aggregateID, 0, 0) 147 | assert.Nil(t, err) 148 | assert.Equal(t, history, found) 149 | assert.Len(t, found, len(history)) 150 | }) 151 | } 152 | 153 | func TestStore_SaveOptimisticLock(t *testing.T) { 154 | ctx := context.Background() 155 | 156 | WithRollback(t, func(db DB, tableName string) { 157 | store, err := mysqlstore.New(tableName, Accessor{db: db}) 158 | assert.Nil(t, err) 159 | 160 | aggregateID := "abc" 161 | initial := eventsource.History{ 162 | { 163 | Version: 1, 164 | Data: []byte("a"), 165 | }, 166 | { 167 | Version: 2, 168 | Data: []byte("b"), 169 | }, 170 | } 171 | // initial save 172 | err = store.Save(ctx, aggregateID, initial...) 173 | assert.Nil(t, err) 174 | 175 | overlap := eventsource.History{ 176 | { 177 | Version: 2, 178 | Data: []byte("c"), 179 | }, 180 | { 181 | Version: 3, 182 | Data: []byte("d"), 183 | }, 184 | } 185 | // save overlapping events; should not be allowed 186 | err = store.Save(ctx, aggregateID, overlap...) 187 | assert.NotNil(t, err) 188 | }) 189 | } 190 | 191 | func TestStore_LoadPartition(t *testing.T) { 192 | WithRollback(t, func(db DB, tableName string) { 193 | store, err := mysqlstore.New(tableName, Accessor{db: db}) 194 | assert.Nil(t, err) 195 | 196 | aggregateID := "abc" 197 | history := eventsource.History{ 198 | { 199 | Version: 1, 200 | Data: []byte("a"), 201 | }, 202 | { 203 | Version: 2, 204 | Data: []byte("b"), 205 | }, 206 | { 207 | Version: 3, 208 | Data: []byte("c"), 209 | }, 210 | } 211 | ctx := context.Background() 212 | err = store.Save(ctx, aggregateID, history...) 213 | assert.Nil(t, err) 214 | 215 | found, err := store.Load(ctx, aggregateID, 0, 1) 216 | assert.Nil(t, err) 217 | assert.Len(t, found, 1) 218 | assert.Equal(t, history[0:1], found) 219 | }) 220 | } 221 | -------------------------------------------------------------------------------- /mysqlstore/util_test.go: -------------------------------------------------------------------------------- 1 | package mysqlstore_test 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/altairsix/eventsource/mysqlstore" 10 | _ "github.com/go-sql-driver/mysql" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var ( 15 | dsn string 16 | ) 17 | 18 | func init() { 19 | env := func(key, defaultValue string) string { 20 | if value := os.Getenv(key); value != "" { 21 | return value 22 | } 23 | return defaultValue 24 | } 25 | user := env("MYSQL_TEST_USER", "altairsix") 26 | name := env("MYSQL_TEST_DBNAME", "altairsix") 27 | pass := env("MYSQL_TEST_PASS", "password") 28 | protocol := env("MYSQL_TEST_PROT", "tcp") 29 | addr := env("MYSQL_TEST_ADDR", "localhost:3306") 30 | netAddr := fmt.Sprintf("%s(%s)", protocol, addr) 31 | dsn = fmt.Sprintf("%s:%s@%s/%s?charset=utf8", user, pass, netAddr, name) 32 | 33 | if os.Getenv("TRAVIS_BUILD_DIR") != "" { 34 | dsn = fmt.Sprintf("%s@/%s?charset=utf8", user, name) 35 | } 36 | } 37 | 38 | type DB interface { 39 | mysqlstore.DB 40 | } 41 | 42 | func WithRollback(t *testing.T, fn func(db DB, tableName string)) { 43 | tableName := "sample" 44 | var db *sql.DB 45 | 46 | v, err := sql.Open("mysql", dsn) 47 | if !assert.Nil(t, err, "unable to open connection") { 48 | return 49 | } 50 | db = v 51 | 52 | if err := mysqlstore.CreateIfNotExists(db, tableName); err != nil { 53 | t.Errorf("unable to create table, %v", err) 54 | return 55 | } 56 | defer db.Close() 57 | 58 | tx, err := db.Begin() 59 | if !assert.Nil(t, err, "unable to begin transaction") { 60 | return 61 | } 62 | defer tx.Rollback() 63 | 64 | fn(tx, tableName) 65 | } 66 | -------------------------------------------------------------------------------- /pgstore/infra.go: -------------------------------------------------------------------------------- 1 | package pgstore 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | const ( 10 | // CreateSQL provides sql to create the event source table 11 | CreateSQL = ` 12 | CREATE TABLE IF NOT EXISTS ${TABLE} ( 13 | id SERIAL PRIMARY KEY, 14 | aggregate_id VARCHAR(255) NOT NULL, 15 | data BYTEA, 16 | version INT 17 | ); 18 | ` 19 | // CheckIndexSQL provides sql to query db to determine whether the index exists 20 | CheckIndexSQL = ` 21 | SELECT count(*) 22 | FROM pg_indexes 23 | WHERE schemaname = 'public' 24 | AND tablename = '${TABLE}' 25 | AND indexname = 'idx_${TABLE}'; 26 | ` 27 | 28 | // CreateIndexSQL provides sql to create the index 29 | CreateIndexSQL = ` 30 | CREATE UNIQUE INDEX idx_${TABLE} 31 | ON ${TABLE} (aggregate_id, version); 32 | ` 33 | ) 34 | 35 | func expand(template, tableName string) string { 36 | return strings.Replace(template, `${TABLE}`, tableName, -1) 37 | } 38 | 39 | // CreateIfNotExists creates the specified table and index(es) in the db if they do not already exist 40 | func CreateIfNotExists(db DB, tableName string) error { 41 | _, err := db.Exec(expand(CreateSQL, tableName)) 42 | if err != nil { 43 | return errors.Wrap(err, "unable to create table") 44 | } 45 | 46 | row, err := db.Query(expand(CheckIndexSQL, tableName)) 47 | if err != nil { 48 | return errors.Wrap(err, "query failed to determine if index exists") 49 | } 50 | 51 | row.Next() 52 | exists := 0 53 | err = row.Scan(&exists) 54 | if err != nil { 55 | return errors.Wrap(err, "unable to read response for whether index exists") 56 | } 57 | 58 | if exists > 0 { 59 | return nil 60 | } 61 | 62 | _, err = db.Exec(expand(CreateIndexSQL, tableName)) 63 | if err != nil { 64 | return errors.Wrap(err, "unable to create index") 65 | } 66 | 67 | return err 68 | } 69 | -------------------------------------------------------------------------------- /pgstore/store.go: -------------------------------------------------------------------------------- 1 | package pgstore 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "math" 8 | "reflect" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/altairsix/eventsource" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | const ( 17 | insertSQL = `INSERT INTO ${TABLE} (aggregate_id, data, version) VALUES ($1, $2, $3)` 18 | selectSQL = `SELECT data, version FROM ${TABLE} WHERE aggregate_id = $1 AND version >= $2 AND version <= $3 ORDER BY version ASC` 19 | readSQL = `SELECT id, aggregate_id, data, version FROM ${TABLE} WHERE id >= $1 ORDER BY ID LIMIT $2` 20 | ) 21 | 22 | // DB provides a smaller surface area for the db calls used; Exec is only used by the create function 23 | type DB interface { 24 | // Exec is implemented by *sql.DB and *sql.Tx 25 | Exec(query string, args ...interface{}) (sql.Result, error) 26 | // PrepareContext is implemented by *sql.DB and *sql.Tx 27 | PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) 28 | // Query is implemented by *sql.DB and *sql.Tx 29 | Query(query string, args ...interface{}) (*sql.Rows, error) 30 | } 31 | 32 | // Accessor provides a standard interface to allow the store to obtain a db connection 33 | type Accessor interface { 34 | // Open requests a new connection 35 | Open(ctx context.Context) (DB, error) 36 | 37 | // Close will be called when the store is finished with the connection 38 | Close(DB) error 39 | } 40 | 41 | // Store provides an eventsource.Store implementation backed by postgres 42 | type Store struct { 43 | tableName string 44 | accessor Accessor 45 | } 46 | 47 | func (s *Store) expand(statement string) string { 48 | return strings.Replace(statement, "${TABLE}", s.tableName, -1) 49 | } 50 | 51 | func (s *Store) maxVersion(ctx context.Context, db DB, aggregateID string) (int, error) { 52 | row, err := db.Query(expand("SELECT MAX(version) FROM ${TABLE} WHERE aggregate_id = $1", s.tableName), aggregateID) 53 | if err != nil { 54 | return 0, errors.Wrap(err, "unable to query database") 55 | } 56 | defer row.Close() 57 | 58 | maxVersion := 0 59 | if row.Next() { 60 | v := sql.NullInt64{} 61 | if err := row.Scan(&v); err != nil { 62 | return 0, errors.Wrap(err, "unable to read version info from database") 63 | } 64 | maxVersion = int(v.Int64) 65 | } 66 | 67 | return maxVersion, nil 68 | } 69 | 70 | // Save the provided serialized records to the store 71 | func (s *Store) Save(ctx context.Context, aggregateID string, records ...eventsource.Record) error { 72 | if len(records) == 0 { 73 | return nil 74 | } 75 | 76 | db, err := s.accessor.Open(ctx) 77 | if err != nil { 78 | return errors.Wrap(err, "save failed; unable to connect to db") 79 | } 80 | defer s.accessor.Close(db) 81 | 82 | maxVersion, err := s.maxVersion(ctx, db, aggregateID) 83 | if err != nil { 84 | return errors.Wrap(err, "save failed; unable to connect to db") 85 | } 86 | 87 | items := eventsource.History(records) 88 | sort.Sort(items) 89 | 90 | if maxVersion >= items[0].Version { 91 | return s.isIdempotent(ctx, db, aggregateID, records...) 92 | } 93 | 94 | stmt, err := db.PrepareContext(ctx, s.expand(insertSQL)) 95 | if err != nil { 96 | return errors.Wrap(err, "unable to prepare statement") 97 | } 98 | defer stmt.Close() 99 | 100 | for _, record := range records { 101 | _, err = stmt.Exec(aggregateID, record.Data, record.Version) 102 | if err != nil { 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | 109 | func (s *Store) isIdempotent(ctx context.Context, db DB, aggregateID string, records ...eventsource.Record) error { 110 | segments := eventsource.History(records) 111 | sort.Sort(segments) 112 | 113 | fromVersion := segments[0].Version 114 | toVersion := segments[len(segments)-1].Version 115 | loaded, err := s.doLoad(ctx, db, aggregateID, fromVersion, toVersion) 116 | if err != nil { 117 | return fmt.Errorf("unable to retrieve version %v-%v for aggregate, %v", fromVersion, toVersion, aggregateID) 118 | } 119 | 120 | if !reflect.DeepEqual(segments, loaded) { 121 | return fmt.Errorf("unable to save records; conflicting records detected for aggregate, %v", aggregateID) 122 | } 123 | 124 | return nil 125 | } 126 | 127 | // Load the history of events up to the version specified; when version is 128 | // 0, all events will be loaded 129 | func (s *Store) Load(ctx context.Context, aggregateID string, fromVersion, toVersion int) (eventsource.History, error) { 130 | db, err := s.accessor.Open(ctx) 131 | if err != nil { 132 | return nil, errors.Wrap(err, "load failed; unable to connect to db") 133 | } 134 | defer s.accessor.Close(db) 135 | 136 | return s.doLoad(ctx, db, aggregateID, fromVersion, toVersion) 137 | } 138 | 139 | func (s *Store) doLoad(ctx context.Context, db DB, aggregateID string, initialVersion, version int) (eventsource.History, error) { 140 | 141 | if version == 0 { 142 | version = math.MaxInt32 143 | } 144 | 145 | rows, err := db.Query(s.expand(selectSQL), aggregateID, initialVersion, version) 146 | if err != nil { 147 | return nil, errors.Wrap(err, "load failed; unable to query rows") 148 | } 149 | 150 | history := eventsource.History{} 151 | for rows.Next() { 152 | record := eventsource.Record{} 153 | if err := rows.Scan(&record.Data, &record.Version); err != nil { 154 | return nil, errors.Wrap(err, "load failed; unable to parse row") 155 | } 156 | history = append(history, record) 157 | } 158 | 159 | return history, nil 160 | } 161 | 162 | func (s *Store) Read(ctx context.Context, startingOffset uint64, recordCount int) ([]eventsource.StreamRecord, error) { 163 | db, err := s.accessor.Open(ctx) 164 | if err != nil { 165 | return nil, errors.Wrap(err, "load failed; unable to connect to db") 166 | } 167 | defer s.accessor.Close(db) 168 | 169 | records := make([]eventsource.StreamRecord, 0, recordCount) 170 | rows, err := db.Query(s.expand(readSQL), startingOffset, recordCount) 171 | if err != nil { 172 | return nil, errors.Wrap(err, "read failed; unable to read records from db") 173 | } 174 | defer rows.Close() 175 | 176 | for rows.Next() { 177 | record := eventsource.StreamRecord{} 178 | if err := rows.Scan(&record.Offset, &record.AggregateID, &record.Data, &record.Version); err != nil { 179 | return nil, errors.Wrapf(err, "failed to scan stream record from db") 180 | } 181 | records = append(records, record) 182 | } 183 | 184 | return records, nil 185 | } 186 | 187 | // New returns a new postgres backed eventsource.Store 188 | func New(tableName string, accessor Accessor) (*Store, error) { 189 | store := &Store{ 190 | tableName: tableName, 191 | accessor: accessor, 192 | } 193 | 194 | return store, nil 195 | } 196 | -------------------------------------------------------------------------------- /pgstore/store_test.go: -------------------------------------------------------------------------------- 1 | package pgstore_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/altairsix/eventsource" 8 | "github.com/altairsix/eventsource/pgstore" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type Accessor struct { 13 | db pgstore.DB 14 | } 15 | 16 | func (a Accessor) Open(ctx context.Context) (pgstore.DB, error) { 17 | return a.db, nil 18 | } 19 | 20 | func (a Accessor) Close(db pgstore.DB) error { 21 | return nil 22 | } 23 | 24 | func TestStore_ImplementsStore(t *testing.T) { 25 | v, err := pgstore.New("blah", nil) 26 | assert.Nil(t, err) 27 | 28 | var store eventsource.Store = v 29 | assert.NotNil(t, store) 30 | } 31 | 32 | func TestStore_ImplementsStreamReader(t *testing.T) { 33 | v, err := pgstore.New("blah", nil) 34 | assert.Nil(t, err) 35 | 36 | var reader eventsource.StreamReader = v 37 | assert.NotNil(t, reader) 38 | } 39 | 40 | func TestStore_SaveEmpty(t *testing.T) { 41 | s, err := pgstore.New("blah", nil) 42 | assert.Nil(t, err) 43 | 44 | err = s.Save(context.Background(), "abc") 45 | assert.Nil(t, err, "no records saved; guaranteed to work") 46 | } 47 | 48 | func TestStore_SaveAndFetch(t *testing.T) { 49 | WithRollback(t, func(db DB, tableName string) { 50 | ctx := context.Background() 51 | store, err := pgstore.New(tableName, Accessor{db: db}) 52 | assert.Nil(t, err) 53 | 54 | aggregateID := "abc" 55 | history := eventsource.History{ 56 | { 57 | Version: 1, 58 | Data: []byte("a"), 59 | }, 60 | { 61 | Version: 2, 62 | Data: []byte("b"), 63 | }, 64 | { 65 | Version: 3, 66 | Data: []byte("c"), 67 | }, 68 | } 69 | err = store.Save(ctx, aggregateID, history...) 70 | assert.Nil(t, err) 71 | 72 | found, err := store.Load(ctx, aggregateID, 0, 0) 73 | assert.Nil(t, err) 74 | assert.Equal(t, history, found) 75 | assert.Len(t, found, len(history)) 76 | }) 77 | } 78 | 79 | func TestStore_SaveAndRead(t *testing.T) { 80 | WithRollback(t, func(db DB, tableName string) { 81 | ctx := context.Background() 82 | store, err := pgstore.New(tableName, Accessor{db: db}) 83 | assert.Nil(t, err) 84 | 85 | aggregateID := "abc" 86 | history := eventsource.History{ 87 | { 88 | Version: 1, 89 | Data: []byte("a"), 90 | }, 91 | { 92 | Version: 2, 93 | Data: []byte("b"), 94 | }, 95 | { 96 | Version: 3, 97 | Data: []byte("c"), 98 | }, 99 | } 100 | err = store.Save(ctx, aggregateID, history...) 101 | assert.Nil(t, err) 102 | 103 | found, err := store.Read(ctx, 0, len(history)) 104 | assert.Nil(t, err) 105 | assert.Len(t, found, len(history)) 106 | 107 | for _, item := range found { 108 | assert.NotZero(t, item.Offset) 109 | assert.NotZero(t, item.AggregateID) 110 | assert.NotZero(t, item.Data) 111 | assert.NotZero(t, item.Version) 112 | } 113 | }) 114 | } 115 | 116 | func TestStore_SaveIdempotent(t *testing.T) { 117 | WithRollback(t, func(db DB, tableName string) { 118 | ctx := context.Background() 119 | store, err := pgstore.New(tableName, Accessor{db: db}) 120 | assert.Nil(t, err) 121 | 122 | aggregateID := "abc" 123 | history := eventsource.History{ 124 | { 125 | Version: 1, 126 | Data: []byte("a"), 127 | }, 128 | { 129 | Version: 2, 130 | Data: []byte("b"), 131 | }, 132 | { 133 | Version: 3, 134 | Data: []byte("c"), 135 | }, 136 | } 137 | // initial save 138 | err = store.Save(ctx, aggregateID, history...) 139 | assert.Nil(t, err) 140 | 141 | // When - save it again 142 | err = store.Save(ctx, aggregateID, history...) 143 | // Then - verify no errors e.g. idempotent 144 | assert.Nil(t, err) 145 | 146 | found, err := store.Load(ctx, aggregateID, 0, 0) 147 | assert.Nil(t, err) 148 | assert.Equal(t, history, found) 149 | assert.Len(t, found, len(history)) 150 | }) 151 | } 152 | 153 | func TestStore_SaveOptimisticLock(t *testing.T) { 154 | ctx := context.Background() 155 | 156 | WithRollback(t, func(db DB, tableName string) { 157 | store, err := pgstore.New(tableName, Accessor{db: db}) 158 | assert.Nil(t, err) 159 | 160 | aggregateID := "abc" 161 | initial := eventsource.History{ 162 | { 163 | Version: 1, 164 | Data: []byte("a"), 165 | }, 166 | { 167 | Version: 2, 168 | Data: []byte("b"), 169 | }, 170 | } 171 | // initial save 172 | err = store.Save(ctx, aggregateID, initial...) 173 | assert.Nil(t, err) 174 | 175 | overlap := eventsource.History{ 176 | { 177 | Version: 2, 178 | Data: []byte("c"), 179 | }, 180 | { 181 | Version: 3, 182 | Data: []byte("d"), 183 | }, 184 | } 185 | // save overlapping events; should not be allowed 186 | err = store.Save(ctx, aggregateID, overlap...) 187 | assert.NotNil(t, err) 188 | }) 189 | } 190 | 191 | func TestStore_LoadPartition(t *testing.T) { 192 | WithRollback(t, func(db DB, tableName string) { 193 | store, err := pgstore.New(tableName, Accessor{db: db}) 194 | assert.Nil(t, err) 195 | 196 | aggregateID := "abc" 197 | history := eventsource.History{ 198 | { 199 | Version: 1, 200 | Data: []byte("a"), 201 | }, 202 | { 203 | Version: 2, 204 | Data: []byte("b"), 205 | }, 206 | { 207 | Version: 3, 208 | Data: []byte("c"), 209 | }, 210 | } 211 | ctx := context.Background() 212 | err = store.Save(ctx, aggregateID, history...) 213 | assert.Nil(t, err) 214 | 215 | found, err := store.Load(ctx, aggregateID, 0, 1) 216 | assert.Nil(t, err) 217 | assert.Len(t, found, 1) 218 | assert.Equal(t, history[0:1], found) 219 | }) 220 | } 221 | -------------------------------------------------------------------------------- /pgstore/util_test.go: -------------------------------------------------------------------------------- 1 | package pgstore_test 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "fmt" 8 | "os" 9 | 10 | "github.com/altairsix/eventsource/pgstore" 11 | _ "github.com/lib/pq" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var ( 16 | dsn string 17 | ) 18 | 19 | func init() { 20 | env := func(key, defaultValue string) string { 21 | if value := os.Getenv(key); value != "" { 22 | return value 23 | } 24 | return defaultValue 25 | } 26 | user := env("POSTGRES_TEST_USER", "altairsix") 27 | pass := env("POSTGRES_TEST_PASS", "password") 28 | host := env("POSTGRES_TEST_HOST", "localhost") 29 | port := env("POSTGRES_TEST_PORT", "5432") 30 | dbname := env("POSTGRES_TEST_DBNAME", "altairsix") 31 | dsn = fmt.Sprintf("host=%v port=%v user=%v password=%v dbname=%v sslmode=disable", 32 | host, port, user, pass, dbname, 33 | ) 34 | } 35 | 36 | type DB interface { 37 | pgstore.DB 38 | } 39 | 40 | func WithRollback(t *testing.T, fn func(db DB, tableName string)) { 41 | tableName := "sample" 42 | var db *sql.DB 43 | 44 | v, err := sql.Open("postgres", dsn) 45 | if !assert.Nil(t, err, "unable to open connection") { 46 | return 47 | } 48 | db = v 49 | 50 | if err := pgstore.CreateIfNotExists(db, tableName); err != nil { 51 | t.Errorf("unable to create table, %v", err) 52 | return 53 | } 54 | defer db.Close() 55 | 56 | tx, err := db.Begin() 57 | if !assert.Nil(t, err, "unable to begin transaction") { 58 | return 59 | } 60 | defer tx.Rollback() 61 | 62 | fn(tx, tableName) 63 | } 64 | -------------------------------------------------------------------------------- /repository.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "reflect" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // Aggregate represents the aggregate root in the domain driven design sense. 14 | // It represents the current state of the domain object and can be thought of 15 | // as a left fold over events. 16 | type Aggregate interface { 17 | // On will be called for each event; returns err if the event could not be 18 | // applied 19 | On(event Event) error 20 | } 21 | 22 | // Repository provides the primary abstraction to saving and loading events 23 | type Repository struct { 24 | prototype reflect.Type 25 | store Store 26 | serializer Serializer 27 | observers []func(Event) 28 | writer io.Writer 29 | debug bool 30 | } 31 | 32 | // Option provides functional configuration for a *Repository 33 | type Option func(*Repository) 34 | 35 | // WithDebug will generate additional logging useful for debugging 36 | func WithDebug(w io.Writer) Option { 37 | return func(r *Repository) { 38 | r.writer = w 39 | r.debug = true 40 | } 41 | } 42 | 43 | // WithStore allows the underlying store to be specified; by default the repository 44 | // uses an in-memory store suitable for testing only 45 | func WithStore(store Store) Option { 46 | return func(r *Repository) { 47 | r.store = store 48 | } 49 | } 50 | 51 | // WithSerializer specifies the serializer to be used 52 | func WithSerializer(serializer Serializer) Option { 53 | return func(r *Repository) { 54 | r.serializer = serializer 55 | } 56 | } 57 | 58 | // WithObservers allows observers to watch the saved events; Observers should invoke very short lived operations as 59 | // calls will block until the observer is finished 60 | func WithObservers(observers ...func(event Event)) Option { 61 | return func(r *Repository) { 62 | r.observers = append(r.observers, observers...) 63 | } 64 | } 65 | 66 | // New creates a new Repository using the JSONSerializer and MemoryStore 67 | func New(prototype Aggregate, opts ...Option) *Repository { 68 | t := reflect.TypeOf(prototype) 69 | if t.Kind() == reflect.Ptr { 70 | t = t.Elem() 71 | } 72 | 73 | r := &Repository{ 74 | prototype: t, 75 | store: newMemoryStore(), 76 | serializer: NewJSONSerializer(), 77 | } 78 | 79 | for _, opt := range opts { 80 | opt(r) 81 | } 82 | 83 | return r 84 | } 85 | 86 | func (r *Repository) logf(format string, args ...interface{}) { 87 | if !r.debug { 88 | return 89 | } 90 | 91 | now := time.Now().Format(time.StampMilli) 92 | io.WriteString(r.writer, now) 93 | io.WriteString(r.writer, " ") 94 | 95 | fmt.Fprintf(r.writer, format, args...) 96 | if !strings.HasSuffix(format, "\n") { 97 | io.WriteString(r.writer, "\n") 98 | } 99 | } 100 | 101 | // New returns a new instance of the aggregate 102 | func (r *Repository) New() Aggregate { 103 | return reflect.New(r.prototype).Interface().(Aggregate) 104 | } 105 | 106 | // Save persists the events into the underlying Store 107 | func (r *Repository) Save(ctx context.Context, events ...Event) error { 108 | if len(events) == 0 { 109 | return nil 110 | } 111 | aggregateID := events[0].AggregateID() 112 | 113 | history := make(History, 0, len(events)) 114 | for _, event := range events { 115 | record, err := r.serializer.MarshalEvent(event) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | history = append(history, record) 121 | } 122 | 123 | return r.store.Save(ctx, aggregateID, history...) 124 | } 125 | 126 | // Load retrieves the specified aggregate from the underlying store 127 | func (r *Repository) Load(ctx context.Context, aggregateID string) (Aggregate, error) { 128 | v, _, err := r.loadVersion(ctx, aggregateID) 129 | return v, err 130 | } 131 | 132 | // loadVersion loads the specified aggregate from the store and returns both the Aggregate and the 133 | // current version number of the aggregate 134 | func (r *Repository) loadVersion(ctx context.Context, aggregateID string) (Aggregate, int, error) { 135 | history, err := r.store.Load(ctx, aggregateID, 0, 0) 136 | if err != nil { 137 | return nil, 0, err 138 | } 139 | 140 | entryCount := len(history) 141 | if entryCount == 0 { 142 | return nil, 0, NewError(nil, ErrAggregateNotFound, "unable to load %v, %v", r.New(), aggregateID) 143 | } 144 | 145 | r.logf("Loaded %v event(s) for aggregate id, %v", entryCount, aggregateID) 146 | aggregate := r.New() 147 | 148 | version := 0 149 | for _, record := range history { 150 | event, err := r.serializer.UnmarshalEvent(record) 151 | if err != nil { 152 | return nil, 0, err 153 | } 154 | 155 | err = aggregate.On(event) 156 | if err != nil { 157 | eventType, _ := EventType(event) 158 | return nil, 0, NewError(err, ErrUnhandledEvent, "aggregate was unable to handle event, %v", eventType) 159 | } 160 | 161 | version = event.EventVersion() 162 | } 163 | 164 | return aggregate, version, nil 165 | } 166 | 167 | // Dispatch executes the command specified 168 | // 169 | // Deprecated: Use Apply instead 170 | func (r *Repository) Dispatch(ctx context.Context, command Command) error { 171 | _, err := r.Apply(ctx, command) 172 | return err 173 | } 174 | 175 | // Apply executes the command specified and returns the current version of the aggregate 176 | func (r *Repository) Apply(ctx context.Context, command Command) (int, error) { 177 | if command == nil { 178 | return 0, errors.New("Command provided to Repository.Dispatch may not be nil") 179 | } 180 | aggregateID := command.AggregateID() 181 | if aggregateID == "" { 182 | return 0, errors.New("Command provided to Repository.Dispatch may not contain a blank AggregateID") 183 | } 184 | 185 | aggregate, version, err := r.loadVersion(ctx, aggregateID) 186 | if err != nil { 187 | aggregate = r.New() 188 | } 189 | 190 | h, ok := aggregate.(CommandHandler) 191 | if !ok { 192 | return 0, fmt.Errorf("Aggregate, %v, does not implement CommandHandler", aggregate) 193 | } 194 | events, err := h.Apply(ctx, command) 195 | if err != nil { 196 | return 0, err 197 | } 198 | 199 | err = r.Save(ctx, events...) 200 | if err != nil { 201 | return 0, err 202 | } 203 | 204 | if v := len(events); v > 0 { 205 | version = events[v-1].EventVersion() 206 | } 207 | 208 | // publish events to observers 209 | if r.observers != nil { 210 | for _, event := range events { 211 | for _, observer := range r.observers { 212 | observer(event) 213 | } 214 | } 215 | } 216 | 217 | return version, nil 218 | } 219 | 220 | // Store returns the underlying Store 221 | func (r *Repository) Store() Store { 222 | return r.store 223 | } 224 | 225 | // Serializer returns the underlying serializer 226 | func (r *Repository) Serializer() Serializer { 227 | return r.serializer 228 | } 229 | -------------------------------------------------------------------------------- /repository_test.go: -------------------------------------------------------------------------------- 1 | package eventsource_test 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "testing" 7 | "time" 8 | 9 | "github.com/altairsix/eventsource" 10 | "github.com/pkg/errors" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type Entity struct { 15 | Version int 16 | ID string 17 | Name string 18 | CreatedAt time.Time 19 | UpdatedAt time.Time 20 | } 21 | 22 | type EntityCreated struct { 23 | eventsource.Model 24 | } 25 | 26 | type EntityNameSet struct { 27 | eventsource.Model 28 | Name string 29 | } 30 | 31 | func (item *Entity) On(event eventsource.Event) error { 32 | switch v := event.(type) { 33 | case *EntityCreated: 34 | item.Version = v.Model.Version 35 | item.ID = v.Model.ID 36 | item.CreatedAt = v.Model.At 37 | item.UpdatedAt = v.Model.At 38 | 39 | case *EntityNameSet: 40 | item.Version = v.Model.Version 41 | item.Name = v.Name 42 | item.UpdatedAt = v.Model.At 43 | 44 | default: 45 | return errors.New(eventsource.ErrUnhandledEvent) 46 | } 47 | 48 | return nil 49 | } 50 | 51 | type CreateEntity struct { 52 | eventsource.CommandModel 53 | } 54 | 55 | type Nop struct { 56 | eventsource.CommandModel 57 | } 58 | 59 | func (item *Entity) Apply(ctx context.Context, command eventsource.Command) ([]eventsource.Event, error) { 60 | switch command.(type) { 61 | case *CreateEntity: 62 | return []eventsource.Event{&EntityCreated{ 63 | Model: eventsource.Model{ 64 | ID: command.AggregateID(), 65 | Version: item.Version + 1, 66 | At: time.Now(), 67 | }, 68 | }}, nil 69 | 70 | case *Nop: 71 | return []eventsource.Event{}, nil 72 | 73 | default: 74 | return []eventsource.Event{}, nil 75 | } 76 | } 77 | 78 | func TestNew(t *testing.T) { 79 | repository := eventsource.New(&Entity{}) 80 | aggregate := repository.New() 81 | assert.NotNil(t, aggregate) 82 | assert.Equal(t, &Entity{}, aggregate) 83 | } 84 | 85 | func TestRepository_Load_NotFound(t *testing.T) { 86 | ctx := context.Background() 87 | repository := eventsource.New(&Entity{}, 88 | eventsource.WithDebug(ioutil.Discard), 89 | ) 90 | 91 | _, err := repository.Load(ctx, "does-not-exist") 92 | assert.NotNil(t, err) 93 | assert.True(t, eventsource.IsNotFound(err)) 94 | } 95 | 96 | func TestRegistry(t *testing.T) { 97 | ctx := context.Background() 98 | id := "123" 99 | name := "Jones" 100 | serializer := eventsource.NewJSONSerializer( 101 | EntityCreated{}, 102 | EntityNameSet{}, 103 | ) 104 | 105 | t.Run("simple", func(t *testing.T) { 106 | repository := eventsource.New(&Entity{}, 107 | eventsource.WithSerializer(serializer), 108 | eventsource.WithDebug(ioutil.Discard), 109 | ) 110 | 111 | // Test - Add an event to the store and verify we can recreate the object 112 | 113 | err := repository.Save(ctx, 114 | &EntityCreated{ 115 | Model: eventsource.Model{ID: id, Version: 0, At: time.Unix(3, 0)}, 116 | }, 117 | &EntityNameSet{ 118 | Model: eventsource.Model{ID: id, Version: 1, At: time.Unix(4, 0)}, 119 | Name: name, 120 | }, 121 | ) 122 | assert.Nil(t, err) 123 | 124 | v, err := repository.Load(ctx, id) 125 | assert.Nil(t, err, "expected successful load") 126 | 127 | org, ok := v.(*Entity) 128 | assert.True(t, ok) 129 | assert.Equal(t, id, org.ID, "expected restored id") 130 | assert.Equal(t, name, org.Name, "expected restored name") 131 | 132 | // Test - Update the org name and verify that the change is reflected in the loaded result 133 | 134 | updated := "Sarah" 135 | err = repository.Save(ctx, &EntityNameSet{ 136 | Model: eventsource.Model{ID: id, Version: 2}, 137 | Name: updated, 138 | }) 139 | assert.Nil(t, err) 140 | 141 | v, err = repository.Load(ctx, id) 142 | assert.Nil(t, err) 143 | 144 | org, ok = v.(*Entity) 145 | assert.True(t, ok) 146 | assert.Equal(t, id, org.ID) 147 | assert.Equal(t, updated, org.Name) 148 | }) 149 | 150 | t.Run("with pointer prototype", func(t *testing.T) { 151 | registry := eventsource.New(&Entity{}, 152 | eventsource.WithSerializer(serializer), 153 | ) 154 | 155 | err := registry.Save(ctx, 156 | &EntityCreated{ 157 | Model: eventsource.Model{ID: id, Version: 0, At: time.Unix(3, 0)}, 158 | }, 159 | &EntityNameSet{ 160 | Model: eventsource.Model{ID: id, Version: 1, At: time.Unix(4, 0)}, 161 | Name: name, 162 | }, 163 | ) 164 | assert.Nil(t, err) 165 | 166 | v, err := registry.Load(ctx, id) 167 | assert.Nil(t, err) 168 | assert.Equal(t, name, v.(*Entity).Name) 169 | }) 170 | 171 | t.Run("with pointer bind", func(t *testing.T) { 172 | registry := eventsource.New(&Entity{}, 173 | eventsource.WithSerializer(serializer), 174 | ) 175 | 176 | err := registry.Save(ctx, 177 | &EntityNameSet{ 178 | Model: eventsource.Model{ID: id, Version: 0}, 179 | Name: name, 180 | }, 181 | ) 182 | assert.Nil(t, err) 183 | 184 | v, err := registry.Load(ctx, id) 185 | assert.Nil(t, err) 186 | assert.Equal(t, name, v.(*Entity).Name) 187 | }) 188 | } 189 | 190 | func TestAt(t *testing.T) { 191 | ctx := context.Background() 192 | id := "123" 193 | 194 | registry := eventsource.New(&Entity{}, 195 | eventsource.WithSerializer(eventsource.NewJSONSerializer(EntityCreated{})), 196 | ) 197 | 198 | err := registry.Save(ctx, 199 | &EntityCreated{ 200 | Model: eventsource.Model{ID: id, Version: 1, At: time.Now()}, 201 | }, 202 | ) 203 | assert.Nil(t, err) 204 | 205 | v, err := registry.Load(ctx, id) 206 | assert.Nil(t, err) 207 | 208 | org := v.(*Entity) 209 | assert.NotZero(t, org.CreatedAt) 210 | assert.NotZero(t, org.UpdatedAt) 211 | } 212 | 213 | func TestRepository_SaveNoEvents(t *testing.T) { 214 | repository := eventsource.New(&Entity{}) 215 | err := repository.Save(context.Background()) 216 | assert.Nil(t, err) 217 | } 218 | 219 | func TestWithObservers(t *testing.T) { 220 | captured := []eventsource.Event{} 221 | observer := func(event eventsource.Event) { 222 | captured = append(captured, event) 223 | } 224 | 225 | repository := eventsource.New(&Entity{}, 226 | eventsource.WithSerializer( 227 | eventsource.NewJSONSerializer( 228 | EntityCreated{}, 229 | EntityNameSet{}, 230 | ), 231 | ), 232 | eventsource.WithDebug(ioutil.Discard), 233 | eventsource.WithObservers(observer), 234 | ) 235 | 236 | ctx := context.Background() 237 | 238 | // When I dispatch command 239 | err := repository.Dispatch(ctx, &CreateEntity{ 240 | CommandModel: eventsource.CommandModel{ID: "abc"}, 241 | }) 242 | 243 | // Then I expect event to be captured 244 | assert.Nil(t, err) 245 | assert.Len(t, captured, 1) 246 | 247 | _, ok := captured[0].(*EntityCreated) 248 | assert.True(t, ok) 249 | } 250 | 251 | func TestApply(t *testing.T) { 252 | repo := eventsource.New(&Entity{}, 253 | eventsource.WithSerializer( 254 | eventsource.NewJSONSerializer( 255 | EntityCreated{}, 256 | ), 257 | ), 258 | ) 259 | 260 | cmd := &CreateEntity{CommandModel: eventsource.CommandModel{ID: "123"}} 261 | 262 | // When 263 | version, err := repo.Apply(context.Background(), cmd) 264 | 265 | // Then 266 | assert.Nil(t, err) 267 | assert.Equal(t, 1, version) 268 | 269 | // And 270 | version, err = repo.Apply(context.Background(), cmd) 271 | 272 | // Then 273 | assert.Nil(t, err) 274 | assert.Equal(t, 2, version) 275 | } 276 | 277 | func TestApplyNopCommand(t *testing.T) { 278 | t.Run("Version still returned when command generates no events", func(t *testing.T) { 279 | repo := eventsource.New(&Entity{}, 280 | eventsource.WithSerializer( 281 | eventsource.NewJSONSerializer( 282 | EntityCreated{}, 283 | ), 284 | ), 285 | ) 286 | 287 | cmd := &Nop{ 288 | CommandModel: eventsource.CommandModel{ID: "abc"}, 289 | } 290 | version, err := repo.Apply(context.Background(), cmd) 291 | assert.Nil(t, err) 292 | assert.Equal(t, 0, version) 293 | }) 294 | } 295 | -------------------------------------------------------------------------------- /scenario/scenario.go: -------------------------------------------------------------------------------- 1 | package scenario 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | 7 | "github.com/altairsix/eventsource" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // CommandHandlerAggregate implements both Aggregate and CommandHandler 12 | type CommandHandlerAggregate interface { 13 | eventsource.CommandHandler 14 | eventsource.Aggregate 15 | } 16 | 17 | // Builder captures the data used to execute a test scenario 18 | type Builder struct { 19 | t assert.TestingT 20 | aggregate CommandHandlerAggregate 21 | given []eventsource.Event 22 | command eventsource.Command 23 | } 24 | 25 | func (b *Builder) clone() *Builder { 26 | return &Builder{ 27 | t: b.t, 28 | aggregate: b.aggregate, 29 | given: b.given, 30 | command: b.command, 31 | } 32 | } 33 | 34 | // Given allows an initial set of events to be provided; may be called multiple times 35 | func (b *Builder) Given(given ...eventsource.Event) *Builder { 36 | dupe := b.clone() 37 | dupe.given = append(dupe.given, given...) 38 | return dupe 39 | } 40 | 41 | // When provides the command to test 42 | func (b *Builder) When(command eventsource.Command) *Builder { 43 | dupe := b.clone() 44 | dupe.command = command 45 | return dupe 46 | } 47 | 48 | func (b *Builder) apply() ([]eventsource.Event, error) { 49 | t := reflect.TypeOf(b.aggregate) 50 | if t.Kind() == reflect.Ptr { 51 | t = t.Elem() 52 | } 53 | aggregate := reflect.New(t).Interface().(CommandHandlerAggregate) 54 | 55 | // given 56 | for _, e := range b.given { 57 | assert.Nil(b.t, aggregate.On(e)) 58 | } 59 | 60 | // when 61 | ctx := context.Background() 62 | return aggregate.Apply(ctx, b.command) 63 | } 64 | 65 | func deepEquals(t assert.TestingT, expected, actual interface{}) bool { 66 | te := reflect.TypeOf(expected) 67 | ta := reflect.TypeOf(actual) 68 | if !assert.Equal(t, te, ta) { 69 | return false 70 | } 71 | 72 | if te.Kind() == reflect.Ptr { 73 | te = te.Elem() 74 | } 75 | 76 | ve := reflect.ValueOf(expected) 77 | if ve.Kind() == reflect.Ptr { 78 | ve = ve.Elem() 79 | } 80 | 81 | va := reflect.ValueOf(actual) 82 | if va.Kind() == reflect.Ptr { 83 | va = va.Elem() 84 | } 85 | 86 | for i := 0; i < te.NumField(); i++ { 87 | fieldType := te.Field(i).Type 88 | if fieldType.Kind() == reflect.Ptr { 89 | fieldType = fieldType.Elem() 90 | } 91 | 92 | fe := ve.Field(i) 93 | fa := va.Field(i) 94 | 95 | if !fe.CanInterface() || !fa.CanInterface() { 96 | continue 97 | } 98 | if zero := reflect.Zero(fieldType).Interface(); zero == fe.Interface() { 99 | continue 100 | } 101 | 102 | if fieldType.Kind() == reflect.Struct { 103 | if ok := deepEquals(t, fe.Interface(), fa.Interface()); !ok { 104 | return false 105 | } 106 | continue 107 | } 108 | 109 | if ok := assert.Equal(t, fe.Interface(), fa.Interface()); !ok { 110 | return false 111 | } 112 | } 113 | 114 | return true 115 | } 116 | 117 | // Then check that the command returns the following events. Only non-zero valued 118 | // fields will be checked. If no non-zeroed values are present, then only the 119 | // event type will be checked 120 | func (b *Builder) Then(expected ...eventsource.Event) { 121 | actual, err := b.apply() 122 | assert.Nil(b.t, err) 123 | 124 | // then 125 | if len(expected) != len(actual) { 126 | assert.Equal(b.t, expected, actual) 127 | return 128 | } 129 | 130 | for index, e := range expected { 131 | a := actual[index] 132 | deepEquals(b.t, e, a) 133 | } 134 | } 135 | 136 | // ThenError verifies that the error returned by the command matches 137 | // the function expectation 138 | func (b *Builder) ThenError(matches func(err error) bool) { 139 | _, err := b.apply() 140 | assert.True(b.t, matches(err)) 141 | } 142 | 143 | // New constructs a new scenario 144 | func New(t assert.TestingT, prototype CommandHandlerAggregate) *Builder { 145 | return &Builder{ 146 | t: t, 147 | aggregate: prototype, 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /scenario/scenario_test.go: -------------------------------------------------------------------------------- 1 | package scenario_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/altairsix/eventsource" 11 | "github.com/altairsix/eventsource/scenario" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | //Order is an example of state generated from left fold of events 16 | type Order struct { 17 | ID string 18 | Version int 19 | CreatedAt time.Time 20 | UpdatedAt time.Time 21 | State string 22 | } 23 | 24 | //OrderCreated event used a marker of order created 25 | type OrderCreated struct { 26 | eventsource.Model 27 | } 28 | 29 | //OrderShipped event used a marker of order shipped 30 | type OrderShipped struct { 31 | eventsource.Model 32 | } 33 | 34 | //On implements Aggregate interface 35 | func (item *Order) On(event eventsource.Event) error { 36 | switch v := event.(type) { 37 | case *OrderCreated: 38 | item.State = "created" 39 | 40 | case *OrderShipped: 41 | item.State = "shipped" 42 | 43 | default: 44 | return fmt.Errorf("unable to handle event, %v", v) 45 | } 46 | 47 | item.Version = event.EventVersion() 48 | item.ID = event.AggregateID() 49 | item.UpdatedAt = event.EventAt() 50 | 51 | return nil 52 | } 53 | 54 | //CreateOrder command 55 | type CreateOrder struct { 56 | eventsource.CommandModel 57 | } 58 | 59 | //ShipOrder command 60 | type ShipOrder struct { 61 | eventsource.CommandModel 62 | } 63 | 64 | func (item *Order) Apply(ctx context.Context, command eventsource.Command) ([]eventsource.Event, error) { 65 | switch v := command.(type) { 66 | case *CreateOrder: 67 | orderCreated := &OrderCreated{ 68 | Model: eventsource.Model{ID: command.AggregateID(), Version: item.Version + 1, At: time.Now()}, 69 | } 70 | return []eventsource.Event{orderCreated}, nil 71 | 72 | case *ShipOrder: 73 | if item.State != "created" { 74 | return nil, fmt.Errorf("order, %v, has already shipped", command.AggregateID()) 75 | } 76 | orderShipped := &OrderShipped{ 77 | Model: eventsource.Model{ID: command.AggregateID(), Version: item.Version + 1, At: time.Now()}, 78 | } 79 | return []eventsource.Event{orderShipped}, nil 80 | 81 | default: 82 | return nil, fmt.Errorf("unhandled command, %v", v) 83 | } 84 | } 85 | 86 | func TestSimpleScenario(t *testing.T) { 87 | scenario.New(t, &Order{}). 88 | Given(). 89 | When(&CreateOrder{}). 90 | Then(&OrderCreated{}) 91 | } 92 | 93 | type Errors struct { 94 | Messages []string 95 | } 96 | 97 | func (e *Errors) Errorf(format string, args ...interface{}) { 98 | e.Messages = append(e.Messages, fmt.Sprintf(format, args...)) 99 | } 100 | 101 | func TestFieldError(t *testing.T) { 102 | errs := &Errors{} 103 | id := "abc" 104 | scenario.New(errs, &Order{}). 105 | Given(). 106 | When( 107 | &CreateOrder{CommandModel: eventsource.CommandModel{ID: id}}, 108 | ). 109 | Then( 110 | &OrderCreated{Model: eventsource.Model{ID: id + "junk"}}, 111 | ) 112 | 113 | assert.Len(t, errs.Messages, 1) 114 | assert.True(t, strings.Contains(errs.Messages[0], "junk")) 115 | } 116 | -------------------------------------------------------------------------------- /serializer.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | ) 7 | 8 | // Serializer converts between Events and Records 9 | type Serializer interface { 10 | // MarshalEvent converts an Event to a Record 11 | MarshalEvent(event Event) (Record, error) 12 | 13 | // UnmarshalEvent converts an Event backed into a Record 14 | UnmarshalEvent(record Record) (Event, error) 15 | } 16 | 17 | type jsonEvent struct { 18 | Type string `json:"t"` 19 | Data json.RawMessage `json:"d"` 20 | } 21 | 22 | // JSONSerializer provides a simple serializer implementation 23 | type JSONSerializer struct { 24 | eventTypes map[string]reflect.Type 25 | } 26 | 27 | // Bind registers the specified events with the serializer; may be called more than once 28 | func (j *JSONSerializer) Bind(events ...Event) { 29 | for _, event := range events { 30 | eventType, t := EventType(event) 31 | j.eventTypes[eventType] = t 32 | } 33 | } 34 | 35 | // MarshalEvent converts an event into its persistent type, Record 36 | func (j *JSONSerializer) MarshalEvent(v Event) (Record, error) { 37 | eventType, _ := EventType(v) 38 | 39 | data, err := json.Marshal(v) 40 | if err != nil { 41 | return Record{}, err 42 | } 43 | 44 | data, err = json.Marshal(jsonEvent{ 45 | Type: eventType, 46 | Data: json.RawMessage(data), 47 | }) 48 | if err != nil { 49 | return Record{}, NewError(err, ErrInvalidEncoding, "unable to encode event") 50 | } 51 | 52 | return Record{ 53 | Version: v.EventVersion(), 54 | Data: data, 55 | }, nil 56 | } 57 | 58 | // UnmarshalEvent converts the persistent type, Record, into an Event instance 59 | func (j *JSONSerializer) UnmarshalEvent(record Record) (Event, error) { 60 | wrapper := jsonEvent{} 61 | err := json.Unmarshal(record.Data, &wrapper) 62 | if err != nil { 63 | return nil, NewError(err, ErrInvalidEncoding, "unable to unmarshal event") 64 | } 65 | 66 | t, ok := j.eventTypes[wrapper.Type] 67 | if !ok { 68 | return nil, NewError(err, ErrUnboundEventType, "unbound event type, %v", wrapper.Type) 69 | } 70 | 71 | v := reflect.New(t).Interface() 72 | err = json.Unmarshal(wrapper.Data, v) 73 | if err != nil { 74 | return nil, NewError(err, ErrInvalidEncoding, "unable to unmarshal event data into %#v", v) 75 | } 76 | 77 | return v.(Event), nil 78 | } 79 | 80 | // MarshalAll is a utility that marshals all the events provided into a History object 81 | func (j *JSONSerializer) MarshalAll(events ...Event) (History, error) { 82 | history := make(History, 0, len(events)) 83 | 84 | for _, event := range events { 85 | record, err := j.MarshalEvent(event) 86 | if err != nil { 87 | return nil, err 88 | } 89 | history = append(history, record) 90 | } 91 | 92 | return history, nil 93 | } 94 | 95 | // NewJSONSerializer constructs a new JSONSerializer and populates it with the specified events. 96 | // Bind may be subsequently called to add more events. 97 | func NewJSONSerializer(events ...Event) *JSONSerializer { 98 | serializer := &JSONSerializer{ 99 | eventTypes: map[string]reflect.Type{}, 100 | } 101 | serializer.Bind(events...) 102 | 103 | return serializer 104 | } 105 | -------------------------------------------------------------------------------- /serializer_test.go: -------------------------------------------------------------------------------- 1 | package eventsource_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/altairsix/eventsource" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type EntitySetName struct { 11 | eventsource.Model 12 | Name string 13 | } 14 | 15 | func TestJSONSerializer(t *testing.T) { 16 | event := EntitySetName{ 17 | Model: eventsource.Model{ 18 | ID: "123", 19 | Version: 456, 20 | }, 21 | Name: "blah", 22 | } 23 | 24 | serializer := eventsource.NewJSONSerializer(event) 25 | record, err := serializer.MarshalEvent(event) 26 | assert.Nil(t, err) 27 | assert.NotNil(t, record) 28 | 29 | v, err := serializer.UnmarshalEvent(record) 30 | assert.Nil(t, err) 31 | 32 | found, ok := v.(*EntitySetName) 33 | assert.True(t, ok) 34 | assert.Equal(t, &event, found) 35 | } 36 | 37 | func TestJSONSerializer_MarshalAll(t *testing.T) { 38 | event := EntitySetName{ 39 | Model: eventsource.Model{ 40 | ID: "123", 41 | Version: 456, 42 | }, 43 | Name: "blah", 44 | } 45 | 46 | serializer := eventsource.NewJSONSerializer(event) 47 | history, err := serializer.MarshalAll(event) 48 | assert.Nil(t, err) 49 | assert.NotNil(t, history) 50 | 51 | v, err := serializer.UnmarshalEvent(history[0]) 52 | assert.Nil(t, err) 53 | 54 | found, ok := v.(*EntitySetName) 55 | assert.True(t, ok) 56 | assert.Equal(t, &event, found) 57 | } 58 | -------------------------------------------------------------------------------- /singleton/README.md: -------------------------------------------------------------------------------- 1 | singleston 2 | ======= 3 | 4 | This package provides a singleton dispatch wrapper. 5 | -------------------------------------------------------------------------------- /singleton/dispatcher.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/altairsix/eventsource" 7 | ) 8 | 9 | // Dispatcher represents a function to execute a command 10 | // 11 | // Deprecated: Use Repository instead 12 | type Dispatcher interface { 13 | // Dispatch calls the command using the repository associated with the dispatcher 14 | Dispatch(ctx context.Context, command eventsource.Command) error 15 | } 16 | 17 | // DispatcherFunc provides a convenience func form of Dispatcher 18 | // 19 | // Deprecated: Use RepositoryFunc instead 20 | type DispatcherFunc func(ctx context.Context, command eventsource.Command) error 21 | 22 | // Dispatch satisfies the Dispatcher interface 23 | func (fn DispatcherFunc) Dispatch(ctx context.Context, command eventsource.Command) error { 24 | return fn(ctx, command) 25 | } 26 | 27 | // Repository represents a function to execute a command that returns the version number 28 | // of the event after the command was applied 29 | type Repository interface { 30 | Apply(ctx context.Context, command eventsource.Command) (int, error) 31 | } 32 | 33 | // RepositoryFunc provides a func convenience wrapper for Repository 34 | type RepositoryFunc func(ctx context.Context, command eventsource.Command) (int, error) 35 | 36 | // Apply satisfies the Repository interface 37 | func (fn RepositoryFunc) Apply(ctx context.Context, command eventsource.Command) (int, error) { 38 | return fn(ctx, command) 39 | } 40 | -------------------------------------------------------------------------------- /singleton/infra.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | ) 7 | 8 | // MakeCreateTableInput is a utility tool to write the default table definition for creating the aws tables 9 | func MakeCreateTableInput(tableName string, readCapacity, writeCapacity int64, opts ...Option) *dynamodb.CreateTableInput { 10 | registry := &Registry{ 11 | region: DefaultRegion, 12 | tableName: tableName, 13 | } 14 | 15 | for _, opt := range opts { 16 | opt(registry) 17 | } 18 | 19 | input := &dynamodb.CreateTableInput{ 20 | TableName: aws.String(tableName), 21 | AttributeDefinitions: []*dynamodb.AttributeDefinition{ 22 | { 23 | AttributeName: aws.String(HashKey), 24 | AttributeType: aws.String("S"), 25 | }, 26 | }, 27 | KeySchema: []*dynamodb.KeySchemaElement{ 28 | { 29 | AttributeName: aws.String(HashKey), 30 | KeyType: aws.String("HASH"), 31 | }, 32 | }, 33 | ProvisionedThroughput: &dynamodb.ProvisionedThroughput{ 34 | ReadCapacityUnits: aws.Int64(readCapacity), 35 | WriteCapacityUnits: aws.Int64(writeCapacity), 36 | }, 37 | } 38 | 39 | return input 40 | } 41 | -------------------------------------------------------------------------------- /singleton/singleton.go: -------------------------------------------------------------------------------- 1 | package singleton 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "time" 8 | 9 | "github.com/altairsix/eventsource" 10 | "github.com/altairsix/eventsource/awscloud" 11 | "github.com/aws/aws-sdk-go/aws" 12 | "github.com/aws/aws-sdk-go/aws/awserr" 13 | "github.com/aws/aws-sdk-go/service/dynamodb" 14 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 15 | ) 16 | 17 | const ( 18 | // DefaultRegion the region the singleton table will be located in by default 19 | DefaultRegion = "us-east-1" 20 | 21 | // HashKey is the hash key for the dynamodb table used by singleton 22 | HashKey = "key" 23 | 24 | // OwnerField is the field that holds the owner 25 | OwnerField = "owner" 26 | 27 | // ExpiresField is the field that holds the expires data 28 | ExpiresField = "expires" 29 | ) 30 | 31 | const ( 32 | ErrIsAlreadyReserved = "err:singleton:already_reserved" 33 | ) 34 | 35 | // Option provides flexibility for configuring a singleton 36 | type Option func(r *Registry) 37 | 38 | // WithDynamoDB allows the caller to specify a pre-configured reference to DynamoDB 39 | func WithDynamoDB(api *dynamodb.DynamoDB) Option { 40 | return func(r *Registry) { 41 | r.api = api 42 | } 43 | } 44 | 45 | // Resource represents the unique e 46 | type Resource struct { 47 | // Type provides a namespace to allow multiple resources to be represented in the same table e.g. email 48 | Type string 49 | 50 | // ID is the unique constraint e.g. normalized email address 51 | ID string 52 | 53 | // Owner is an arbitrary string that identifies who is in possession of the resource. 54 | // The owner of the resource may call #Reserve any number of times. 55 | Owner string 56 | } 57 | 58 | // Key converts the Resource into the dynamodb hash key 59 | func (r Resource) Key() string { 60 | return r.Type + ":" + r.ID 61 | } 62 | 63 | // Interface provides the interface that Commands must implement to be picked up 64 | // by the singleton registry 65 | type Interface interface { 66 | eventsource.Command 67 | Reserve() (Resource, time.Duration) 68 | } 69 | 70 | // Registry provides an API into the allocations that have been made 71 | type Registry struct { 72 | tableName string 73 | region string 74 | endpoint string 75 | api *dynamodb.DynamoDB 76 | } 77 | 78 | // record provides a struct representation of what is stored in dynamodb 79 | type record struct { 80 | Key string `dynamodbav:"key"` 81 | Owner string `dynamodbav:"owner"` 82 | ExpiresAt int64 `dynamodbav:"expires"` 83 | } 84 | 85 | // IsAvailable indicates whether the resource is available to be reserved; nil indicate the 86 | // resource is available 87 | func (r *Registry) IsAvailable(ctx context.Context, resource Resource) error { 88 | out, err := r.api.GetItem(&dynamodb.GetItemInput{ 89 | TableName: aws.String(r.tableName), 90 | ConsistentRead: aws.Bool(true), 91 | Key: map[string]*dynamodb.AttributeValue{ 92 | HashKey: {S: aws.String(resource.Key())}, 93 | }, 94 | }) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | if len(out.Item) == 0 { 100 | // empty object 101 | return nil 102 | } 103 | 104 | item := &record{} 105 | err = dynamodbattribute.UnmarshalMap(out.Item, item) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | if item.Owner != resource.Owner { 111 | return fmt.Errorf("not the owner") 112 | } 113 | 114 | if item.ExpiresAt < time.Now().Unix() { 115 | return fmt.Errorf("not found") 116 | } 117 | 118 | return nil 119 | } 120 | 121 | // Reserve the resource for the owner specified by the resource for the period specified 122 | // If d == 0; then the reservation lasts forever 123 | func (r *Registry) Reserve(ctx context.Context, resource Resource, d time.Duration) error { 124 | expiresAt := time.Now().Add(d).Unix() 125 | if d == 0 { 126 | expiresAt = math.MaxInt64 127 | } 128 | 129 | item, err := dynamodbattribute.MarshalMap(&record{ 130 | Key: resource.Key(), 131 | Owner: resource.Owner, 132 | ExpiresAt: expiresAt, 133 | }) 134 | if err != nil { 135 | return err 136 | } 137 | 138 | _, err = r.api.PutItem(&dynamodb.PutItemInput{ 139 | TableName: aws.String(r.tableName), 140 | Item: item, 141 | ConditionExpression: aws.String("attribute_not_exists(#key) or #owner = :owner"), 142 | ExpressionAttributeNames: map[string]*string{ 143 | "#key": aws.String(HashKey), 144 | "#owner": aws.String(OwnerField), 145 | }, 146 | ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{ 147 | ":owner": {S: aws.String(resource.Owner)}, 148 | }, 149 | }) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return nil 155 | } 156 | 157 | // Release removes the reservation for an existing resource so it can be reserved 158 | // again 159 | func (r *Registry) Release(ctx context.Context, resource Resource) error { 160 | _, err := r.api.DeleteItem(&dynamodb.DeleteItemInput{ 161 | TableName: aws.String(r.tableName), 162 | Key: map[string]*dynamodb.AttributeValue{ 163 | HashKey: {S: aws.String(resource.Key())}, 164 | }, 165 | }) 166 | return err 167 | } 168 | 169 | // reserve determines whether the command is requesting a reservation and if it does, it performs the reservation 170 | func (r *Registry) reserve(ctx context.Context, cmd eventsource.Command) error { 171 | v, ok := cmd.(Interface) 172 | if !ok { 173 | return nil 174 | } 175 | 176 | resource, duration := v.Reserve() 177 | err := r.Reserve(ctx, resource, duration) 178 | if err != nil { 179 | if v, ok := err.(awserr.Error); ok && v.Code() == dynamodb.ErrCodeConditionalCheckFailedException { 180 | return eventsource.NewError(err, ErrIsAlreadyReserved, "%v resource already exists, %v", resource.Type, resource.ID) 181 | } 182 | return err 183 | } 184 | 185 | return nil 186 | } 187 | 188 | // Wrap wraps a dispatcher with the singleton handler and returns a new dispatcher. 189 | // If any command implements singleton.Interface, the wrapped dispatcher will 190 | // attempt to reserve the specified resource for 191 | func (r *Registry) Wrap(dispatcher Dispatcher) Dispatcher { 192 | return DispatcherFunc(func(ctx context.Context, command eventsource.Command) error { 193 | if err := r.reserve(ctx, command); err != nil { 194 | return err 195 | } 196 | 197 | return dispatcher.Dispatch(ctx, command) 198 | }) 199 | } 200 | 201 | // WrapRepository wraps an *eventsource.Repository and returns a new Repository that implements the Apply method. 202 | // If any command implements singleton.Interface, the wrapped dispatcher will 203 | // attempt to reserve the specified resource for 204 | func (r *Registry) WrapRepository(repo Repository) Repository { 205 | return RepositoryFunc(func(ctx context.Context, command eventsource.Command) (int, error) { 206 | if err := r.reserve(ctx, command); err != nil { 207 | return 0, err 208 | } 209 | 210 | return repo.Apply(ctx, command) 211 | }) 212 | } 213 | 214 | // IsAlreadyReserved returns true if the error indicates the resource already exists and is reserved by someone else 215 | func IsAlreadyReserved(err error) bool { 216 | return eventsource.ErrHasCode(err, ErrIsAlreadyReserved) 217 | } 218 | 219 | // New constructs a new singleton registry to simplify access to resoure reservations 220 | func New(tableName string, opts ...Option) (*Registry, error) { 221 | registry := &Registry{ 222 | tableName: tableName, 223 | region: DefaultRegion, 224 | } 225 | 226 | for _, opt := range opts { 227 | opt(registry) 228 | } 229 | 230 | if registry.api == nil { 231 | v, err := awscloud.DynamoDB(registry.region, registry.endpoint) 232 | if err != nil { 233 | return nil, err 234 | } 235 | registry.api = v 236 | } 237 | 238 | return registry, nil 239 | } 240 | -------------------------------------------------------------------------------- /singleton/singleton_test.go: -------------------------------------------------------------------------------- 1 | package singleton_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/altairsix/eventsource" 10 | "github.com/altairsix/eventsource/awscloud" 11 | "github.com/altairsix/eventsource/dynamodbstore" 12 | "github.com/altairsix/eventsource/singleton" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestRegistry_Lifecycle(t *testing.T) { 17 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 18 | if endpoint == "" { 19 | t.SkipNow() 20 | return 21 | } 22 | 23 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 24 | assert.Nil(t, err) 25 | 26 | ctx := context.Background() 27 | resource := singleton.Resource{ 28 | Type: "email", 29 | ID: "id", 30 | Owner: "abc", 31 | } 32 | other := singleton.Resource{ 33 | Type: resource.Type, 34 | ID: resource.ID, 35 | Owner: resource.Owner + "blah", 36 | } 37 | 38 | TempTable(t, api, func(tableName string) { 39 | registry, err := singleton.New(tableName, 40 | singleton.WithDynamoDB(api), 41 | ) 42 | assert.Nil(t, err) 43 | 44 | // Should be available, no one's allocated it 45 | err = registry.IsAvailable(ctx, resource) 46 | assert.Nil(t, err) 47 | 48 | // Reserve it 49 | err = registry.Reserve(ctx, resource, time.Hour) 50 | assert.Nil(t, err) 51 | 52 | // Owner should show it as available 53 | err = registry.IsAvailable(ctx, resource) 54 | assert.Nil(t, err) 55 | 56 | // But others will see it as occupied 57 | err = registry.IsAvailable(ctx, other) 58 | assert.NotNil(t, err) 59 | 60 | // However, once we release it 61 | err = registry.Release(ctx, resource) 62 | assert.Nil(t, err) 63 | 64 | // Others may see it as available 65 | err = registry.IsAvailable(ctx, other) 66 | assert.Nil(t, err) 67 | }) 68 | } 69 | 70 | func TestRegistry_ReleaseIdempotent(t *testing.T) { 71 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 72 | if endpoint == "" { 73 | t.SkipNow() 74 | return 75 | } 76 | 77 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 78 | assert.Nil(t, err) 79 | 80 | ctx := context.Background() 81 | resource := singleton.Resource{ 82 | Type: "email", 83 | ID: "id", 84 | Owner: "abc", 85 | } 86 | 87 | TempTable(t, api, func(tableName string) { 88 | registry, err := singleton.New(tableName, 89 | singleton.WithDynamoDB(api), 90 | ) 91 | assert.Nil(t, err) 92 | 93 | // Reserve it 94 | err = registry.Reserve(ctx, resource, time.Hour) 95 | assert.Nil(t, err) 96 | 97 | // However, once we release it 98 | err = registry.Release(ctx, resource) 99 | assert.Nil(t, err) 100 | 101 | // However, once we release it 102 | err = registry.Release(ctx, resource) 103 | assert.Nil(t, err) 104 | }) 105 | } 106 | 107 | func TestRegistry_AllocateIdempotent(t *testing.T) { 108 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 109 | if endpoint == "" { 110 | t.SkipNow() 111 | return 112 | } 113 | 114 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 115 | assert.Nil(t, err) 116 | 117 | ctx := context.Background() 118 | resource := singleton.Resource{ 119 | Type: "email", 120 | ID: "id", 121 | Owner: "abc", 122 | } 123 | 124 | TempTable(t, api, func(tableName string) { 125 | registry, err := singleton.New(tableName, 126 | singleton.WithDynamoDB(api), 127 | ) 128 | assert.Nil(t, err) 129 | 130 | // Reserve it 131 | err = registry.Reserve(ctx, resource, time.Hour) 132 | assert.Nil(t, err) 133 | 134 | // Reserve it 135 | err = registry.Reserve(ctx, resource, time.Hour) 136 | assert.Nil(t, err) 137 | }) 138 | } 139 | 140 | func TestRegistry_Wrap(t *testing.T) { 141 | endpoint := os.Getenv("DYNAMODB_ENDPOINT") 142 | if endpoint == "" { 143 | t.SkipNow() 144 | return 145 | } 146 | 147 | api, err := awscloud.DynamoDB(dynamodbstore.DefaultRegion, endpoint) 148 | assert.Nil(t, err) 149 | 150 | ctx := context.Background() 151 | resource := singleton.Resource{ 152 | Type: "email", 153 | ID: "id", 154 | Owner: "user-1", 155 | } 156 | 157 | TempTable(t, api, func(tableName string) { 158 | registry, err := singleton.New(tableName, 159 | singleton.WithDynamoDB(api), 160 | ) 161 | assert.Nil(t, err) 162 | 163 | // user-1 allocates it 164 | err = registry.Reserve(ctx, resource, time.Hour) 165 | assert.Nil(t, err) 166 | 167 | fn := singleton.DispatcherFunc(func(ctx context.Context, command eventsource.Command) error { 168 | return nil 169 | }) 170 | dispatcher := registry.Wrap(fn) 171 | 172 | // the original allocator can dispatch the command 173 | err = dispatcher.Dispatch(ctx, Command{ 174 | ID: resource.ID, 175 | Owner: resource.Owner, 176 | }) 177 | assert.Nil(t, err) 178 | 179 | // but another user cannot 180 | err = dispatcher.Dispatch(ctx, Command{ 181 | ID: resource.ID, 182 | Owner: resource.Owner + "blah", 183 | }) 184 | assert.NotNil(t, err) 185 | assert.True(t, singleton.IsAlreadyReserved(err)) 186 | }) 187 | } 188 | 189 | type Command struct { 190 | eventsource.CommandModel 191 | ID string 192 | Owner string 193 | } 194 | 195 | func (c Command) Reserve() (singleton.Resource, time.Duration) { 196 | return singleton.Resource{ 197 | Type: "email", 198 | ID: c.ID, 199 | Owner: c.Owner, 200 | }, time.Hour 201 | } 202 | -------------------------------------------------------------------------------- /singleton/util_test.go: -------------------------------------------------------------------------------- 1 | package singleton_test 2 | 3 | import ( 4 | "math/rand" 5 | "strconv" 6 | "testing" 7 | "time" 8 | 9 | "github.com/altairsix/eventsource/singleton" 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/service/dynamodb" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var ( 16 | r = rand.New(rand.NewSource(time.Now().UnixNano())) 17 | ) 18 | 19 | func TempTable(t *testing.T, api *dynamodb.DynamoDB, fn func(tableName string)) { 20 | // Create a temporary table for use during this test 21 | // 22 | now := strconv.FormatInt(time.Now().UnixNano(), 36) 23 | random := strconv.FormatInt(int64(r.Int31()), 36) 24 | tableName := "tmp-" + now + "-" + random 25 | input := singleton.MakeCreateTableInput(tableName, 50, 50) 26 | _, err := api.CreateTable(input) 27 | assert.Nil(t, err) 28 | defer func() { 29 | _, err := api.DeleteTable(&dynamodb.DeleteTableInput{TableName: aws.String(tableName)}) 30 | assert.Nil(t, err) 31 | }() 32 | 33 | fn(tableName) 34 | } 35 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "sync" 7 | ) 8 | 9 | // Record provides the serialized representation of the event 10 | type Record struct { 11 | // Version contains the version associated with the serialized event 12 | Version int 13 | 14 | // Data contains the event in serialized form 15 | Data []byte 16 | } 17 | 18 | // History represents 19 | type History []Record 20 | 21 | // Len implements sort.Interface 22 | func (h History) Len() int { 23 | return len(h) 24 | } 25 | 26 | // Swap implements sort.Interface 27 | func (h History) Swap(i, j int) { 28 | h[i], h[j] = h[j], h[i] 29 | } 30 | 31 | // Less implements sort.Interface 32 | func (h History) Less(i, j int) bool { 33 | return h[i].Version < h[j].Version 34 | } 35 | 36 | // Store provides an abstraction for the Repository to save data 37 | type Store interface { 38 | // Save the provided serialized records to the store 39 | Save(ctx context.Context, aggregateID string, records ...Record) error 40 | 41 | // Load the history of events up to the version specified. 42 | // When toVersion is 0, all events will be loaded. 43 | // To start at the beginning, fromVersion should be set to 0 44 | Load(ctx context.Context, aggregateID string, fromVersion, toVersion int) (History, error) 45 | } 46 | 47 | // memoryStore provides an in-memory implementation of Store 48 | type memoryStore struct { 49 | mux *sync.Mutex 50 | eventsByID map[string]History 51 | } 52 | 53 | func newMemoryStore() *memoryStore { 54 | return &memoryStore{ 55 | mux: &sync.Mutex{}, 56 | eventsByID: map[string]History{}, 57 | } 58 | } 59 | 60 | func (m *memoryStore) Save(ctx context.Context, aggregateID string, records ...Record) error { 61 | if _, ok := m.eventsByID[aggregateID]; !ok { 62 | m.eventsByID[aggregateID] = History{} 63 | } 64 | 65 | history := append(m.eventsByID[aggregateID], records...) 66 | sort.Sort(history) 67 | m.eventsByID[aggregateID] = history 68 | 69 | return nil 70 | } 71 | 72 | func (m *memoryStore) Load(ctx context.Context, aggregateID string, fromVersion, toVersion int) (History, error) { 73 | all, ok := m.eventsByID[aggregateID] 74 | if !ok { 75 | return nil, NewError(nil, ErrAggregateNotFound, "no aggregate found with id, %v", aggregateID) 76 | } 77 | 78 | history := make(History, 0, len(all)) 79 | if len(all) > 0 { 80 | for _, record := range all { 81 | if v := record.Version; v >= fromVersion && (toVersion == 0 || v <= toVersion) { 82 | history = append(history, record) 83 | } 84 | } 85 | } 86 | 87 | return all, nil 88 | } 89 | -------------------------------------------------------------------------------- /store_test.go: -------------------------------------------------------------------------------- 1 | package eventsource_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "sort" 7 | 8 | "github.com/altairsix/eventsource" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestHistory_Swap(t *testing.T) { 13 | history := eventsource.History{ 14 | {Version: 3}, 15 | {Version: 1}, 16 | {Version: 2}, 17 | } 18 | 19 | sort.Sort(history) 20 | assert.Equal(t, 1, history[0].Version) 21 | assert.Equal(t, 2, history[1].Version) 22 | assert.Equal(t, 3, history[2].Version) 23 | } 24 | -------------------------------------------------------------------------------- /stream.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import "context" 4 | 5 | // StreamRecord provides a serialized version of the event stream 6 | type StreamRecord struct { 7 | Record 8 | Offset uint64 9 | AggregateID string 10 | } 11 | 12 | // StreamReader allows one to query the raw event stream to read the next N events 13 | // This is particular useful to publish events in cases where the underlying event store 14 | // can't publish events on its own e.g. databases 15 | type StreamReader interface { 16 | // Read reads the next recordCount records from the event store starting at the specified 17 | // offset 18 | Read(ctx context.Context, startingOffset uint64, recordCount int) ([]StreamRecord, error) 19 | } 20 | 21 | // StreamReaderFunc provides an func alternative for declaring a StreamReader 22 | type StreamReaderFunc func(ctx context.Context, startingOffset uint64, recordCount int) ([]StreamRecord, error) 23 | 24 | // Read implements the StreamReader.Read interface 25 | func (fn StreamReaderFunc) Read(ctx context.Context, startingOffset uint64, recordCount int) ([]StreamRecord, error) { 26 | return fn(ctx, startingOffset, recordCount) 27 | } 28 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package eventsource 2 | 3 | import "reflect" 4 | 5 | // EventType is a helper func that extracts the event type of the event along with the reflect.Type of the event. 6 | // 7 | // Primarily useful for serializers that need to understand how marshal and unmarshal instances of Event to a []byte 8 | func EventType(event Event) (string, reflect.Type) { 9 | t := reflect.TypeOf(event) 10 | if t.Kind() == reflect.Ptr { 11 | t = t.Elem() 12 | } 13 | 14 | if v, ok := event.(EventTyper); ok { 15 | return v.EventType(), t 16 | } 17 | 18 | return t.Name(), t 19 | } 20 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package eventsource_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/altairsix/eventsource" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type Custom struct { 11 | eventsource.Model 12 | } 13 | 14 | func (c Custom) EventType() string { 15 | return "blah" 16 | } 17 | 18 | func TestEventType(t *testing.T) { 19 | m := Custom{} 20 | eventType, _ := eventsource.EventType(m) 21 | assert.Equal(t, "blah", eventType) 22 | } 23 | --------------------------------------------------------------------------------