├── gen.go ├── vars.go ├── buf.gen.yaml ├── pb └── es_options.proto ├── gen ├── pb │ ├── ref.proto │ ├── query.proto │ ├── command.proto │ └── event.proto ├── esoptions │ └── es_options.pb.go ├── ref.pb.go ├── query.pb.go ├── event.pb.go └── command.pb.go ├── buf.yaml ├── examples ├── subscribers │ ├── events.go │ ├── commands.go │ ├── init.go │ ├── main.go │ ├── service.go │ ├── projection.go │ └── aggregate.go ├── natserver │ └── main.go ├── query │ └── main.go └── publishers │ └── main.go ├── co └── co.go ├── qo └── qo.go ├── .github └── workflows │ └── go.yml ├── .gitignore ├── go.mod ├── helpers.go ├── eo └── eo.go ├── po └── po.go ├── publish.go ├── context.go ├── query.go ├── ro └── ro.go ├── event.go ├── go.sum ├── projector.go ├── protoc-gen-bee └── main.go ├── replay.go ├── eventsregistry.go ├── command.go ├── README.md ├── LICENSE └── bee_test.go /gen.go: -------------------------------------------------------------------------------- 1 | //go:generate protoc --go_out=. ./pb/es_options.proto 2 | 3 | package bee 4 | -------------------------------------------------------------------------------- /vars.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | var EventsPrefix = "events" 4 | var CommandsPrefix = "cmds" 5 | var QueryPrefix = "query" 6 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | managed: 3 | enabled: false 4 | plugins: 5 | - plugin: go 6 | out: gen 7 | opt: paths=source_relative 8 | -------------------------------------------------------------------------------- /pb/es_options.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package esoptions; 4 | 5 | option go_package = "gen/esoptions"; 6 | 7 | import "google/protobuf/descriptor.proto"; 8 | 9 | extend google.protobuf.FieldOptions { 10 | bool aggregate_id = 50001; 11 | } 12 | -------------------------------------------------------------------------------- /gen/pb/ref.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package gen; 4 | option go_package = "github.com/blinkinglight/bee/gen"; // sugeneruos tiesiai į gen/ 5 | 6 | import "google/protobuf/struct.proto"; 7 | 8 | import "google/protobuf/timestamp.proto"; 9 | 10 | message ParentRef { 11 | string aggregate_type = 1; 12 | string aggregate_id = 2; 13 | } -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | modules: 3 | # Each module entry defines a path, which must be relative to the directory where the 4 | # buf.yaml is located. You can also specify directories to exclude from a module. 5 | - path: gen/pb 6 | # Modules can also optionally specify their Buf Schema Repository name if it exists. 7 | name: github.com/ituoga/iot -------------------------------------------------------------------------------- /examples/subscribers/events.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type UserCreated struct { 4 | Name string `json:"name"` 5 | Country string `json:"country"` 6 | } 7 | 8 | type UserUpdated struct { 9 | Name string `json:"name"` 10 | Country string `json:"country"` 11 | } 12 | 13 | type UserNameChanged struct { 14 | Name string `json:"name"` 15 | } 16 | type UserDeleted struct { 17 | } 18 | -------------------------------------------------------------------------------- /examples/subscribers/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type CreateUserCommand struct { 4 | Name string `json:"name"` 5 | Country string `json:"country"` 6 | } 7 | 8 | type UpdateUserCommand struct { 9 | Name string `json:"name"` 10 | Country string `json:"country"` 11 | } 12 | 13 | type ChangeUserNameCommand struct { 14 | Name string `json:"name"` 15 | } 16 | 17 | type DeleteUserCommand struct { 18 | } 19 | -------------------------------------------------------------------------------- /gen/pb/query.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package gen; 4 | option go_package = "github.com/blinkinglight/bee/gen"; // sugeneruos tiesiai į gen/ 5 | 6 | import "google/protobuf/struct.proto"; 7 | 8 | message QueryEnvelope { 9 | string query_type = 1; 10 | bytes payload = 2; 11 | google.protobuf.Struct extra_metadata = 3; 12 | string tenant_id = 4; 13 | string correlation_id = 5; 14 | string user_id = 6; 15 | } 16 | -------------------------------------------------------------------------------- /examples/natserver/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/delaneyj/toolbelt/embeddednats" 8 | ) 9 | 10 | func main() { 11 | 12 | ns, err := embeddednats.New(context.Background(), embeddednats.WithShouldClearData(true)) 13 | if err != nil { 14 | panic(err) 15 | } 16 | defer ns.Close() 17 | 18 | ns.WaitForServer() 19 | log.Printf("NATS server is running at %s", ns.NatsServer.ClientURL()) 20 | select {} 21 | } 22 | -------------------------------------------------------------------------------- /co/co.go: -------------------------------------------------------------------------------- 1 | package co 2 | 3 | type Options func(*Config) 4 | 5 | type Config struct { 6 | Subject string 7 | Aggregate string 8 | } 9 | 10 | // WithSubject overrides default subject behavior 11 | func WithSubject(subject string) Options { 12 | return func(cfg *Config) { 13 | cfg.Subject = subject 14 | } 15 | } 16 | 17 | // WithAggregateID sets the aggregate 18 | func WithAggreate(aggregate string) Options { 19 | return func(cfg *Config) { 20 | cfg.Aggregate = aggregate 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /qo/qo.go: -------------------------------------------------------------------------------- 1 | package qo 2 | 3 | type Options func(*Config) 4 | 5 | type Config struct { 6 | Subject string 7 | Aggregate string 8 | } 9 | 10 | // WithSubject overrides default subject behavior 11 | func WithSubject(subject string) Options { 12 | return func(cfg *Config) { 13 | cfg.Subject = subject 14 | } 15 | } 16 | 17 | // WithAggreate sets the aggregate type for the subscription 18 | func WithAggreate(aggregate string) Options { 19 | return func(cfg *Config) { 20 | cfg.Aggregate = aggregate 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.24' 23 | 24 | - name: Test 25 | run: go test -v ./... 26 | -------------------------------------------------------------------------------- /examples/subscribers/init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/blinkinglight/bee" 4 | 5 | func init() { 6 | bee.RegisterEvent[UserCreated]("users", "created") 7 | bee.RegisterEvent[UserUpdated]("users", "updated") 8 | bee.RegisterEvent[UserDeleted]("users", "deleted") 9 | bee.RegisterEvent[UserNameChanged]("users", "name_changed") 10 | 11 | bee.RegisterCommand[CreateUserCommand]("users", "create") 12 | bee.RegisterCommand[UpdateUserCommand]("users", "update") 13 | bee.RegisterCommand[ChangeUserNameCommand]("users", "change_name") 14 | bee.RegisterCommand[DeleteUserCommand]("users", "delete") 15 | } 16 | -------------------------------------------------------------------------------- /gen/pb/command.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package gen; 4 | option go_package = "github.com/blinkinglight/bee/gen"; 5 | 6 | import "google/protobuf/timestamp.proto"; 7 | import "google/protobuf/struct.proto"; 8 | 9 | import "ref.proto"; 10 | 11 | message CommandEnvelope { 12 | string command_id = 1; 13 | string correlation_id = 2; 14 | google.protobuf.Timestamp timestamp = 3; 15 | string user_id = 4; 16 | string aggregate = 5; 17 | string aggregate_id = 6; 18 | string command_type = 7; 19 | bytes payload = 8; 20 | map metadata = 9; 21 | google.protobuf.Struct extra_metadata = 10; 22 | string tenant_id = 11; 23 | repeated ParentRef parents = 12; 24 | } 25 | 26 | 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Code coverage profiles and other test artifacts 15 | *.out 16 | coverage.* 17 | *.coverprofile 18 | profile.cov 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | 30 | # Editor/IDE 31 | # .idea/ 32 | # .vscode/ 33 | tmpdata 34 | -------------------------------------------------------------------------------- /gen/pb/event.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package gen; 4 | option go_package = "github.com/blinkinglight/bee/gen"; // sugeneruos tiesiai į gen/ 5 | 6 | import "google/protobuf/struct.proto"; 7 | 8 | import "google/protobuf/timestamp.proto"; 9 | 10 | import "ref.proto"; 11 | 12 | message EventEnvelope { 13 | string event_id = 1; 14 | string correlation_id = 2; 15 | google.protobuf.Timestamp timestamp = 3; 16 | string aggregate_type = 4; 17 | string aggregate_id = 5; 18 | string event_type = 6; 19 | bytes payload = 7; 20 | string user_id = 8; 21 | map metadata = 9; 22 | google.protobuf.Struct extra_metadata = 10; 23 | string tenant_id = 11; 24 | repeated ParentRef parents = 12; 25 | 26 | } -------------------------------------------------------------------------------- /examples/query/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/blinkinglight/bee/gen" 8 | "github.com/nats-io/nats.go" 9 | "google.golang.org/protobuf/proto" 10 | "google.golang.org/protobuf/types/known/structpb" 11 | ) 12 | 13 | func main() { 14 | nc, err := nats.Connect(nats.DefaultURL) 15 | if err != nil { 16 | panic(err) 17 | } 18 | defer nc.Close() 19 | s, _ := structpb.NewStruct(map[string]any{ 20 | "id": "user-2408", 21 | }) 22 | q := gen.QueryEnvelope{ 23 | QueryType: "one", 24 | ExtraMetadata: s, 25 | } 26 | b, _ := proto.Marshal(&q) 27 | r, err := nc.Request("query.users.get", b, 3*time.Second) 28 | if err != nil { 29 | log.Fatalf("Failed to send query: %v", err) 30 | } 31 | log.Printf("%s", r.Data) 32 | } 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/blinkinglight/bee 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/delaneyj/toolbelt v0.4.3 7 | github.com/nats-io/nats-server/v2 v2.10.25 8 | github.com/nats-io/nats.go v1.43.0 9 | google.golang.org/protobuf v1.36.6 10 | ) 11 | 12 | require ( 13 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 14 | github.com/google/go-cmp v0.6.0 // indirect 15 | github.com/klauspost/compress v1.18.0 // indirect 16 | github.com/minio/highwayhash v1.0.3 // indirect 17 | github.com/nats-io/jwt/v2 v2.7.3 // indirect 18 | github.com/nats-io/nkeys v0.4.11 // indirect 19 | github.com/nats-io/nuid v1.0.1 // indirect 20 | golang.org/x/crypto v0.37.0 // indirect 21 | golang.org/x/sys v0.32.0 // indirect 22 | golang.org/x/time v0.10.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import "encoding/json" 4 | 5 | // Unmarshal unmarshals JSON data into a struct of type T. 6 | // It returns the struct and an error if unmarshaling fails. 7 | 8 | func Unmarshal[T any](data []byte) (T, error) { 9 | var msg T 10 | if err := json.Unmarshal(data, &msg); err != nil { 11 | return msg, err 12 | } 13 | return msg, nil 14 | } 15 | 16 | // MustUnmarshal unmarshals JSON data into a struct of type T. 17 | // It panics if unmarshaling fails, so it should be used when you are sure 18 | // that the data is valid and will not cause an error. 19 | // This is useful for tests or when you want to ensure that the data is always valid. 20 | func MustUnmarshal[T any](data []byte) T { 21 | t, err := Unmarshal[T](data) 22 | if err != nil { 23 | panic(err) 24 | } 25 | return t 26 | } 27 | -------------------------------------------------------------------------------- /examples/subscribers/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | 7 | "github.com/blinkinglight/bee" 8 | "github.com/blinkinglight/bee/co" 9 | "github.com/blinkinglight/bee/po" 10 | "github.com/blinkinglight/bee/qo" 11 | "github.com/nats-io/nats.go" 12 | ) 13 | 14 | func main() { 15 | ctx := context.Background() 16 | 17 | nc, err := nats.Connect(nats.DefaultURL) 18 | 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | js, err := nc.JetStream() 24 | 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | ctx = bee.WithNats(ctx, nc) 30 | ctx = bee.WithJetStream(ctx, js) 31 | 32 | go bee.Command(ctx, NewService(ctx), co.WithAggreate("users")) 33 | 34 | go bee.Project(ctx, NewUserProjection(), po.WithAggreate("users")) 35 | 36 | go bee.Query(ctx, NewUserProjection(), qo.WithAggreate("users")) 37 | 38 | runtime.Goexit() 39 | } 40 | -------------------------------------------------------------------------------- /eo/eo.go: -------------------------------------------------------------------------------- 1 | package eo 2 | 3 | type Options func(*Config) 4 | 5 | type Config struct { 6 | Subject string 7 | Aggregate string 8 | AggregateID string 9 | DurableName string 10 | Prefix string 11 | } 12 | 13 | // WithSubject overrides default subject behavior 14 | func WithSubject(subject string) Options { 15 | return func(cfg *Config) { 16 | cfg.Subject = subject 17 | } 18 | } 19 | 20 | // WithAggregateID sets the aggregate 21 | func WithAggreate(aggregate string) Options { 22 | return func(cfg *Config) { 23 | cfg.Aggregate = aggregate 24 | } 25 | } 26 | 27 | // WithAggregateID sets the aggregate ID 28 | func WithAggregateID(aggregateID string) Options { 29 | return func(cfg *Config) { 30 | cfg.AggregateID = aggregateID 31 | } 32 | } 33 | 34 | // WithDurableName sets the durable name for the subscription 35 | func WithDurableName(durableName string) Options { 36 | return func(cfg *Config) { 37 | cfg.DurableName = durableName 38 | } 39 | } 40 | 41 | // WithPrefix sets the prefix for the subject 42 | func WithPrefix(prefix string) Options { 43 | return func(cfg *Config) { 44 | cfg.Prefix = prefix 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/subscribers/service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/blinkinglight/bee" 8 | "github.com/blinkinglight/bee/gen" 9 | "github.com/blinkinglight/bee/ro" 10 | "google.golang.org/protobuf/types/known/structpb" 11 | ) 12 | 13 | func NewService(ctx context.Context) *UserService { 14 | return &UserService{ctx: ctx} 15 | } 16 | 17 | type UserService struct { 18 | ctx context.Context 19 | } 20 | 21 | func (s UserService) Handle(m *gen.CommandEnvelope) ([]*gen.EventEnvelope, error) { 22 | agg := NewAggregate(m.AggregateId) 23 | 24 | bee.Replay(s.ctx, agg, ro.WithAggreate(m.Aggregate), ro.WithAggregateID(m.AggregateId)) 25 | 26 | if agg.Found && m.CommandType == "create" { 27 | return nil, fmt.Errorf("aggregate %s with ID %s already exists", m.Aggregate, m.AggregateId) 28 | } 29 | 30 | m.ExtraMetadata, _ = structpb.NewStruct(map[string]any{ 31 | "foo": 1, 32 | "bar": "baz", 33 | "baz": []any{"a", "b", "c"}, 34 | "qux": map[string]any{"key1": "value1", "key2": 2}, 35 | "quux": true, 36 | "quuz": 3.14, 37 | "corge": nil, 38 | }) 39 | 40 | return agg.ApplyCommand(s.ctx, m) 41 | } 42 | -------------------------------------------------------------------------------- /po/po.go: -------------------------------------------------------------------------------- 1 | package po 2 | 3 | type Options func(*Config) 4 | 5 | type Config struct { 6 | Subject string 7 | Aggregate string 8 | AggregateID string 9 | DurableName string 10 | Prefix string 11 | } 12 | 13 | // WithSubject overrides default subject behavior 14 | func WithSubject(subject string) Options { 15 | return func(cfg *Config) { 16 | cfg.Subject = subject 17 | } 18 | } 19 | 20 | // WithDurable sets the durable name for the subscription 21 | func WithDurable(name string) Options { 22 | return func(cfg *Config) { 23 | cfg.DurableName = name 24 | } 25 | } 26 | 27 | // WithAggreate sets the aggregate type for the subscription 28 | func WithAggreate(aggregate string) Options { 29 | return func(cfg *Config) { 30 | cfg.Aggregate = aggregate 31 | } 32 | } 33 | 34 | // WithAggrateID sets the aggregate ID for the subscription 35 | func WithAggrateID(aggregateID string) Options { 36 | return func(cfg *Config) { 37 | cfg.AggregateID = aggregateID 38 | } 39 | } 40 | 41 | // WithPrefix sets a prefix for the durable name 42 | func WithPrefix(prefix string) Options { 43 | return func(cfg *Config) { 44 | cfg.Prefix = prefix 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /publish.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | 8 | "github.com/blinkinglight/bee/gen" 9 | "google.golang.org/protobuf/proto" 10 | ) 11 | 12 | // PublishCommand publishes a command to the JetStream server. 13 | // It takes a context, a CommandEnvelope, and an optional payload. 14 | // If the payload is not nil, it marshals the payload into JSON and sets it in the CommandEnvelope. 15 | // It retrieves the JetStream context from the context and publishes the command to the subject 16 | // "cmds." with the serialized CommandEnvelope as the message body. 17 | // If the JetStream context is not initialized, it returns an error. 18 | // The function returns an error if the publish operation fails. 19 | func PublishCommand(ctx context.Context, cmd *gen.CommandEnvelope, payload any) error { 20 | if payload != nil { 21 | b, _ := json.Marshal(payload) 22 | cmd.Payload = b 23 | } 24 | 25 | js, _ := JetStream(ctx) 26 | if js == nil { 27 | return errors.New("JetStream is not initialized") 28 | } 29 | 30 | b, _ := proto.Marshal(cmd) 31 | _, err := js.Publish(CommandsPrefix+"."+cmd.Aggregate, b) 32 | return err 33 | } 34 | -------------------------------------------------------------------------------- /examples/subscribers/projection.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/blinkinglight/bee" 7 | "github.com/blinkinglight/bee/gen" 8 | ) 9 | 10 | func NewUserProjection() *UserProjection { 11 | return &UserProjection{} 12 | } 13 | 14 | type UserProjection struct { 15 | } 16 | 17 | func (up UserProjection) ApplyEvent(e *gen.EventEnvelope) error { 18 | event, err := bee.UnmarshalEvent(e) 19 | if err != nil { 20 | log.Printf("error unmarshalling event: %v", err) 21 | } 22 | switch event := event.(type) { 23 | case *UserCreated: 24 | println("User created:", event.Name, "from", event.Country) 25 | case *UserUpdated: 26 | println("User updated:", event.Name, "from", event.Country) 27 | case *UserDeleted: 28 | println("User deleted with ID:", e.AggregateId) 29 | default: 30 | println("Unknown event type:", e.EventType) 31 | } 32 | return nil 33 | } 34 | 35 | func (p UserProjection) Query(query *gen.QueryEnvelope) (any, error) { 36 | log.Printf("got query on subject %s %s", query.QueryType, query.ExtraMetadata.AsMap()) 37 | switch query.QueryType { 38 | case "one": 39 | return "query one fake result", nil 40 | case "many": 41 | return "query many fake result", nil 42 | case "any": 43 | return "query any fake result", nil 44 | default: 45 | return nil, nil 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/publishers/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/blinkinglight/bee/gen" 9 | "github.com/nats-io/nats.go" 10 | "google.golang.org/protobuf/proto" 11 | ) 12 | 13 | func main() { 14 | nc, err := nats.Connect(nats.DefaultURL) 15 | if err != nil { 16 | panic(err) 17 | } 18 | defer nc.Close() 19 | 20 | js, err := nc.JetStream() 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | base, _ := strconv.Atoi(os.Args[1]) 26 | _ = base 27 | for i := 0; i < 20; i++ { 28 | cmd := &gen.CommandEnvelope{ 29 | Aggregate: "users", 30 | AggregateId: fmt.Sprintf("user-%d", base+i), 31 | CommandType: "create", 32 | CorrelationId: fmt.Sprintf("correlation-%d", base+i), 33 | Payload: []byte(`{"name": "User ` + fmt.Sprintf("%d", i) + `", "country": "Country ` + fmt.Sprintf("%d", i) + `"}`), 34 | } 35 | b, _ := proto.Marshal(cmd) 36 | js.Publish("cmds.users", b) 37 | } 38 | 39 | { 40 | i := 9 41 | cmd := &gen.CommandEnvelope{ 42 | Aggregate: "users", 43 | AggregateId: fmt.Sprintf("user-%d", base+i), 44 | CommandType: "delete", 45 | CorrelationId: fmt.Sprintf("correlation-%d", base+i), 46 | Payload: nil, 47 | } 48 | b, _ := proto.Marshal(cmd) 49 | js.Publish("cmds.users", b) 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/nats-io/nats.go" 7 | ) 8 | 9 | type key[T any] string 10 | 11 | func with[T any](ctx context.Context, k key[T], v T) context.Context { 12 | return context.WithValue(ctx, k, v) 13 | } 14 | 15 | func get[T any](ctx context.Context, k key[T]) (T, bool) { 16 | v, ok := ctx.Value(k).(T) 17 | return v, ok 18 | } 19 | 20 | var natsKey = key[*nats.Conn]("natsKey") 21 | 22 | // WithNats adds a NATS connection to the context. 23 | func WithNats(ctx context.Context, nc *nats.Conn) context.Context { 24 | return with(ctx, natsKey, nc) 25 | } 26 | 27 | // Nats retrieves the NATS connection from the context. 28 | func Nats(ctx context.Context) (*nats.Conn, bool) { 29 | nc, ok := get(ctx, natsKey) 30 | if !ok { 31 | return nil, false 32 | } 33 | return nc, true 34 | } 35 | 36 | var jsKey = key[nats.JetStreamContext]("jsKey") 37 | 38 | // WithJetStream adds a JetStream context to the context. 39 | func WithJetStream(ctx context.Context, js nats.JetStreamContext) context.Context { 40 | return with(ctx, jsKey, js) 41 | } 42 | 43 | // JetStream retrieves the JetStream context from the context. 44 | func JetStream(ctx context.Context) (nats.JetStreamContext, bool) { 45 | js, ok := get(ctx, jsKey) 46 | if !ok { 47 | return nil, false 48 | } 49 | return js, true 50 | } 51 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/blinkinglight/bee/gen" 8 | "github.com/blinkinglight/bee/qo" 9 | "github.com/nats-io/nats.go" 10 | "google.golang.org/protobuf/proto" 11 | ) 12 | 13 | // Querier is a function type that takes a QueryEnvelope and returns a result or an error. 14 | // qo.Subnect - use custom subject instead of default "query.aggregate.get" 15 | // qo.Aggregate - use custom aggregate 16 | func Query(ctx context.Context, fn Querier, opts ...qo.Options) error { 17 | cfg := &qo.Config{ 18 | Aggregate: "*", 19 | } 20 | 21 | for _, opt := range opts { 22 | opt(cfg) 23 | } 24 | 25 | subject := QueryPrefix + "." + cfg.Aggregate + ".get" 26 | if cfg.Subject != "" { 27 | subject = cfg.Subject 28 | } 29 | 30 | nc, _ := Nats(ctx) 31 | 32 | _, _ = nc.QueueSubscribe(subject, cfg.Aggregate, func(msg *nats.Msg) { 33 | if msg == nil { 34 | return 35 | } 36 | 37 | query := &gen.QueryEnvelope{} 38 | if err := proto.Unmarshal(msg.Data, query); err != nil { 39 | msg.Respond([]byte(err.Error())) 40 | return 41 | } 42 | 43 | result, err := fn.Query(query) 44 | if err != nil { 45 | msg.Respond([]byte(err.Error())) 46 | return 47 | } 48 | response, err := json.Marshal(result) 49 | if err != nil { 50 | msg.Respond([]byte(err.Error())) 51 | return 52 | } 53 | msg.Respond(response) 54 | 55 | }) 56 | 57 | <-ctx.Done() 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /ro/ro.go: -------------------------------------------------------------------------------- 1 | package ro 2 | 3 | import "time" 4 | 5 | type Options func(*Config) 6 | 7 | type Config struct { 8 | Subject string 9 | Aggregate string 10 | AggregateID string 11 | StartSeq uint64 12 | Timeout time.Duration 13 | EventType string 14 | Parents []string 15 | } 16 | 17 | // WithEventType sets the EventType 18 | func WithEventType(eventType string) Options { 19 | return func(cfg *Config) { 20 | cfg.EventType = eventType 21 | } 22 | } 23 | 24 | // WithParent appends a parent aggregate and ID to the Parents 25 | // modifies subject into nested structure 26 | // e.g. parent1.id1.parent2.id2.parent3.id3.aggregate.id 27 | func WithParent(aggreate, id string) Options { 28 | return func(cfg *Config) { 29 | cfg.Parents = append(cfg.Parents, aggreate+"."+id) 30 | } 31 | } 32 | 33 | // WithSubject sets the Subject 34 | func WithSubject(subject string) Options { 35 | return func(cfg *Config) { 36 | cfg.Subject = subject 37 | } 38 | } 39 | 40 | // WithAggreate sets the Aggregate 41 | func WithAggreate(aggregate string) Options { 42 | return func(cfg *Config) { 43 | cfg.Aggregate = aggregate 44 | } 45 | } 46 | 47 | // WithStartSeq sets the StartSeq 48 | func WithStartSeq(seq uint64) Options { 49 | return func(cfg *Config) { 50 | cfg.StartSeq = seq 51 | } 52 | } 53 | 54 | // WithAggregateID sets the AggregateID 55 | func WithAggregateID(id string) Options { 56 | return func(cfg *Config) { 57 | cfg.AggregateID = id 58 | } 59 | } 60 | 61 | // WithTimeout sets the Timeout 62 | func WithTimeout(timeout time.Duration) Options { 63 | return func(cfg *Config) { 64 | cfg.Timeout = timeout 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/subscribers/aggregate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/blinkinglight/bee" 8 | "github.com/blinkinglight/bee/gen" 9 | ) 10 | 11 | // --- UserAggregate implements ES aggregate logic --- 12 | type UserAggregate struct { 13 | ID string 14 | Name string 15 | Country string 16 | Deleted bool 17 | 18 | Found bool // Used to check if the aggregate was found during replay 19 | } 20 | 21 | type User struct { 22 | Name string `json:"name"` 23 | Country string `json:"country"` 24 | } 25 | 26 | func NewAggregate(id string) *UserAggregate { 27 | return &UserAggregate{ID: id} 28 | } 29 | 30 | func (u *UserAggregate) ApplyEvent(e *gen.EventEnvelope) error { 31 | u.Found = true 32 | 33 | event, err := bee.UnmarshalEvent(e) 34 | if err != nil { 35 | return fmt.Errorf("failed to unmarshal event: %w", err) 36 | } 37 | 38 | switch event := event.(type) { 39 | case *UserCreated: 40 | u.Name = event.Name 41 | u.Country = event.Country 42 | u.Deleted = false 43 | case *UserUpdated: 44 | u.Country = event.Country 45 | u.Name = event.Name 46 | case *UserDeleted: 47 | u.Deleted = true 48 | } 49 | return nil 50 | } 51 | 52 | func (u *UserAggregate) ApplyCommand(_ context.Context, c *gen.CommandEnvelope) ([]*gen.EventEnvelope, error) { 53 | if c.AggregateId != u.ID { 54 | return nil, fmt.Errorf("aggregate ID mismatch") 55 | } 56 | 57 | _ = c.ExtraMetadata.AsMap() 58 | 59 | var event *gen.EventEnvelope = &gen.EventEnvelope{AggregateId: u.ID} 60 | event.AggregateType = "users" 61 | 62 | command, err := bee.UnmarshalCommand(c) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to unmarshal command: %w", err) 65 | } 66 | 67 | switch command.(type) { 68 | case *CreateUserCommand: 69 | event.EventType = "created" 70 | event.Payload = c.Payload 71 | case *UpdateUserCommand: 72 | if u.Deleted { 73 | return nil, fmt.Errorf("cannot update deleted user") 74 | } 75 | event.EventType = "updated" 76 | event.Payload = c.Payload 77 | case *DeleteUserCommand: 78 | if u.Deleted { 79 | return nil, fmt.Errorf("user already deleted") 80 | } 81 | event.EventType = "deleted" 82 | default: 83 | return nil, fmt.Errorf("unknown command type: %s", c.CommandType) 84 | } 85 | return []*gen.EventEnvelope{event}, nil 86 | } 87 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/blinkinglight/bee/eo" 9 | "github.com/blinkinglight/bee/gen" 10 | "github.com/nats-io/nats.go" 11 | "google.golang.org/protobuf/proto" 12 | ) 13 | 14 | type ManagerEventApplier interface { 15 | Handle(event *gen.EventEnvelope) ([]*gen.CommandEnvelope, error) 16 | } 17 | 18 | func Event(ctx context.Context, fn ManagerEventApplier, opts ...eo.Options) error { 19 | 20 | cfg := &eo.Config{ 21 | AggregateID: "*", 22 | } 23 | 24 | for _, opt := range opts { 25 | opt(cfg) 26 | } 27 | 28 | if cfg.Aggregate == "" { 29 | panic("aggregate is required for projection") 30 | } 31 | 32 | if cfg.DurableName == "" { 33 | cfg.DurableName = cfg.Aggregate 34 | } 35 | 36 | if cfg.Subject == "" { 37 | cfg.Subject = fmt.Sprintf(EventsPrefix+".%s.%s.>", cfg.Aggregate, cfg.AggregateID) 38 | } 39 | 40 | prefix := "" 41 | if cfg.Prefix != "" { 42 | prefix = cfg.Prefix + "_" 43 | } 44 | 45 | js, _ := JetStream(ctx) 46 | 47 | js.AddStream(&nats.StreamConfig{ 48 | Name: "EVENTS", 49 | Subjects: []string{EventsPrefix + ".>"}, 50 | Storage: nats.FileStorage, 51 | Retention: nats.LimitsPolicy, 52 | MaxAge: 0, 53 | Replicas: 1, 54 | }) 55 | 56 | sub, err := js.Subscribe(cfg.Subject, func(msg *nats.Msg) { 57 | if msg == nil { 58 | return 59 | } 60 | evt := &gen.EventEnvelope{} 61 | if err := proto.Unmarshal(msg.Data, evt); err != nil { 62 | msg.Ack() 63 | return 64 | } 65 | 66 | commands, err := fn.Handle(evt) 67 | if err != nil { 68 | msg.Ack() 69 | return 70 | } 71 | for _, command := range commands { 72 | if command.Aggregate == "" { 73 | command.Aggregate = evt.AggregateType 74 | } 75 | eventSubject := fmt.Sprintf(CommandsPrefix+".%s", command.Aggregate) 76 | b, _ := proto.Marshal(command) 77 | if _, err := js.Publish(eventSubject, b); err != nil { 78 | log.Printf("Error publishing event %v", err) 79 | } 80 | } 81 | 82 | _ = msg.Ack() 83 | }, nats.DeliverAll(), nats.ManualAck(), nats.Durable("procmgrs_"+prefix+cfg.DurableName), nats.BindStream("EVENTS"), nats.ConsumerName(cfg.DurableName)) 84 | if err != nil { 85 | fmt.Printf("Error subscribing to events: %v\n", err) 86 | return err 87 | } 88 | defer sub.Unsubscribe() 89 | 90 | <-ctx.Done() 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 2 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 3 | github.com/delaneyj/toolbelt v0.4.3 h1:FPserVJwnNR5+2Mb6iF2/vZNPh3NVxYK7AB+yFZgCPU= 4 | github.com/delaneyj/toolbelt v0.4.3/go.mod h1:IroTekxVLSiGnbJLIJneqriQ4RjkNpTHDhr1jl9lLMM= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 8 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 9 | github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= 10 | github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= 11 | github.com/nats-io/jwt/v2 v2.7.3 h1:6bNPK+FXgBeAqdj4cYQ0F8ViHRbi7woQLq4W29nUAzE= 12 | github.com/nats-io/jwt/v2 v2.7.3/go.mod h1:GvkcbHhKquj3pkioy5put1wvPxs78UlZ7D/pY+BgZk4= 13 | github.com/nats-io/nats-server/v2 v2.10.25 h1:J0GWLDDXo5HId7ti/lTmBfs+lzhmu8RPkoKl0eSCqwc= 14 | github.com/nats-io/nats-server/v2 v2.10.25/go.mod h1:/YYYQO7cuoOBt+A7/8cVjuhWTaTUEAlZbJT+3sMAfFU= 15 | github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug= 16 | github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= 17 | github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= 18 | github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= 19 | github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= 20 | github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 21 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 22 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 23 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 24 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 25 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 26 | golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= 27 | golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 28 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 29 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 30 | -------------------------------------------------------------------------------- /gen/esoptions/es_options.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc v5.29.3 5 | // source: pb/es_options.proto 6 | 7 | package esoptions 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | descriptorpb "google.golang.org/protobuf/types/descriptorpb" 13 | reflect "reflect" 14 | unsafe "unsafe" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | var file_pb_es_options_proto_extTypes = []protoimpl.ExtensionInfo{ 25 | { 26 | ExtendedType: (*descriptorpb.FieldOptions)(nil), 27 | ExtensionType: (*bool)(nil), 28 | Field: 50001, 29 | Name: "esoptions.aggregate_id", 30 | Tag: "varint,50001,opt,name=aggregate_id", 31 | Filename: "pb/es_options.proto", 32 | }, 33 | } 34 | 35 | // Extension fields to descriptorpb.FieldOptions. 36 | var ( 37 | // optional bool aggregate_id = 50001; 38 | E_AggregateId = &file_pb_es_options_proto_extTypes[0] 39 | ) 40 | 41 | var File_pb_es_options_proto protoreflect.FileDescriptor 42 | 43 | const file_pb_es_options_proto_rawDesc = "" + 44 | "\n" + 45 | "\x13pb/es_options.proto\x12\tesoptions\x1a google/protobuf/descriptor.proto:B\n" + 46 | "\faggregate_id\x12\x1d.google.protobuf.FieldOptions\x18ц\x03 \x01(\bR\vaggregateIdB\x0fZ\rgen/esoptionsb\x06proto3" 47 | 48 | var file_pb_es_options_proto_goTypes = []any{ 49 | (*descriptorpb.FieldOptions)(nil), // 0: google.protobuf.FieldOptions 50 | } 51 | var file_pb_es_options_proto_depIdxs = []int32{ 52 | 0, // 0: esoptions.aggregate_id:extendee -> google.protobuf.FieldOptions 53 | 1, // [1:1] is the sub-list for method output_type 54 | 1, // [1:1] is the sub-list for method input_type 55 | 1, // [1:1] is the sub-list for extension type_name 56 | 0, // [0:1] is the sub-list for extension extendee 57 | 0, // [0:0] is the sub-list for field type_name 58 | } 59 | 60 | func init() { file_pb_es_options_proto_init() } 61 | func file_pb_es_options_proto_init() { 62 | if File_pb_es_options_proto != nil { 63 | return 64 | } 65 | type x struct{} 66 | out := protoimpl.TypeBuilder{ 67 | File: protoimpl.DescBuilder{ 68 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 69 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_pb_es_options_proto_rawDesc), len(file_pb_es_options_proto_rawDesc)), 70 | NumEnums: 0, 71 | NumMessages: 0, 72 | NumExtensions: 1, 73 | NumServices: 0, 74 | }, 75 | GoTypes: file_pb_es_options_proto_goTypes, 76 | DependencyIndexes: file_pb_es_options_proto_depIdxs, 77 | ExtensionInfos: file_pb_es_options_proto_extTypes, 78 | }.Build() 79 | File_pb_es_options_proto = out.File 80 | file_pb_es_options_proto_goTypes = nil 81 | file_pb_es_options_proto_depIdxs = nil 82 | } 83 | -------------------------------------------------------------------------------- /projector.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/blinkinglight/bee/gen" 9 | "github.com/blinkinglight/bee/po" 10 | "github.com/nats-io/nats.go" 11 | "google.golang.org/protobuf/proto" 12 | ) 13 | 14 | type EventApplier interface { 15 | ApplyEvent(event *gen.EventEnvelope) error 16 | } 17 | 18 | type Querier interface { 19 | Query(query *gen.QueryEnvelope) (interface{}, error) 20 | } 21 | 22 | type Projector interface { 23 | EventApplier 24 | Querier 25 | } 26 | 27 | // Project subscribes to events for a specific aggregate and applies them using the provided EventApplier function. 28 | // It uses JetStream to manage the event stream and durable subscriptions. 29 | // The function takes a context, an EventApplier function, and optional configuration options. 30 | // The configuration options allow customization of the aggregate type, aggregate ID, subject, durable name, 31 | // and prefix for the subscription. 32 | // po.WithSubject sets the subject for the subscription 33 | // po.WithAggreate sets the aggregate type for the subscription 34 | // po.WithAggrateID sets the aggregate ID for the subscription 35 | // po.WithPrefix sets a prefix for the durable name 36 | // po.WithDurable sets the durable name for the subscription 37 | func Project(ctx context.Context, fn EventApplier, opts ...po.Options) error { 38 | 39 | cfg := &po.Config{ 40 | AggregateID: "*", 41 | } 42 | 43 | for _, opt := range opts { 44 | opt(cfg) 45 | } 46 | 47 | if cfg.Aggregate == "" { 48 | return fmt.Errorf("aggregate is required for projection") 49 | } 50 | 51 | if cfg.DurableName == "" { 52 | cfg.DurableName = cfg.Aggregate 53 | } 54 | 55 | if cfg.Subject == "" { 56 | cfg.Subject = fmt.Sprintf(EventsPrefix+".%s.%s.>", cfg.Aggregate, cfg.AggregateID) 57 | } 58 | 59 | prefix := "" 60 | if cfg.Prefix != "" { 61 | prefix = cfg.Prefix + "_" 62 | } 63 | 64 | js, ok := JetStream(ctx) 65 | if !ok { 66 | return fmt.Errorf("JetStream not available in context") 67 | } 68 | 69 | _, _ = js.AddStream(&nats.StreamConfig{ 70 | Name: "EVENTS", 71 | Subjects: []string{EventsPrefix + ".>"}, 72 | Storage: nats.FileStorage, 73 | Retention: nats.LimitsPolicy, 74 | MaxAge: 0, 75 | Replicas: 1, 76 | }) 77 | 78 | sub, err := js.Subscribe(cfg.Subject, func(msg *nats.Msg) { 79 | if msg == nil { 80 | return 81 | } 82 | m := &gen.EventEnvelope{} 83 | if err := proto.Unmarshal(msg.Data, m); err != nil { 84 | log.Printf("Error unmarshalling event: aggregate=%s, aggregateID=%s, eventType=%s, error=%v", 85 | cfg.Aggregate, cfg.AggregateID, msg.Subject, err) 86 | msg.Ack() 87 | return 88 | } 89 | 90 | if err := fn.ApplyEvent(m); err != nil { 91 | log.Printf("Error applying event: aggregate=%s, aggregateID=%s, eventType=%s, error=%v", 92 | m.AggregateType, m.AggregateId, m.EventType, err) 93 | msg.Ack() 94 | return 95 | } 96 | msg.Ack() 97 | }, nats.DeliverAll(), nats.ManualAck(), nats.Durable("events_"+prefix+cfg.DurableName), nats.BindStream("EVENTS"), nats.ConsumerName(cfg.DurableName)) 98 | if err != nil { 99 | fmt.Printf("Error subscribing to events: %v\n", err) 100 | return fmt.Errorf("projector: failed to subscribe to subject %s: %w", cfg.Subject, err) 101 | 102 | } 103 | defer sub.Unsubscribe() 104 | 105 | <-ctx.Done() 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /protoc-gen-bee/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "text/template" 7 | 8 | "github.com/blinkinglight/bee/gen/esoptions" 9 | "google.golang.org/protobuf/compiler/protogen" 10 | "google.golang.org/protobuf/proto" 11 | "google.golang.org/protobuf/types/descriptorpb" 12 | ) 13 | 14 | var eventTemplate = template.Must(template.New("event").Parse(`package {{.Package}} 15 | 16 | 17 | {{range .Events}} 18 | type {{.Name}} struct { 19 | 20 | {{range .Fields}}{{.GoName}} {{.GoType}} // {{.IsAggregateID}} 21 | {{end}} 22 | } 23 | 24 | {{end}} 25 | `)) 26 | 27 | var commandTemplate = template.Must(template.New("command").Parse(`package {{.Package}} 28 | 29 | {{range .Commands}} 30 | type {{.Name}} struct { 31 | {{range .Fields}}{{.GoName}} {{.GoType}} 32 | {{end}} 33 | } 34 | 35 | {{end}} 36 | `)) 37 | 38 | type Field struct { 39 | GoName string 40 | GoType string 41 | IsAggregateID bool 42 | } 43 | 44 | type Message struct { 45 | Name string 46 | Fields []Field 47 | Aggregate string 48 | EventType string 49 | } 50 | 51 | func isAggregateID(field *protogen.Field) bool { 52 | opts, ok := field.Desc.Options().(*descriptorpb.FieldOptions) 53 | if !ok || opts == nil { 54 | return false 55 | } 56 | val := proto.GetExtension(opts, esoptions.E_AggregateId) 57 | if b, ok := val.(bool); ok { 58 | return b 59 | } 60 | return false 61 | } 62 | func main() { 63 | protogen.Options{}.Run(func(plugin *protogen.Plugin) error { 64 | for _, file := range plugin.Files { 65 | if !file.Generate { 66 | continue 67 | } 68 | 69 | var events []Message 70 | var commands []Message 71 | pkg := string(file.GoPackageName) 72 | 73 | // Paprastumo dėlei: Aggregate bus pirmas message (Project). 74 | var aggregateName string 75 | if len(file.Messages) > 0 { 76 | aggregateName = string(file.Messages[0].GoIdent.GoName) 77 | } 78 | 79 | for _, msg := range file.Messages { 80 | fields := []Field{} 81 | for _, f := range msg.Fields { 82 | fields = append(fields, Field{ 83 | GoName: string(f.GoName), 84 | GoType: goType(f), 85 | IsAggregateID: isAggregateID(f), 86 | }) 87 | } 88 | m := Message{ 89 | Name: string(msg.GoIdent.GoName), 90 | Fields: fields, 91 | Aggregate: aggregateName, 92 | EventType: string(msg.GoIdent.GoName) + "ed", // quick hack 93 | } 94 | 95 | n := m.Name 96 | // "Command" detection 97 | if strings.HasPrefix(n, "Create") || strings.HasSuffix(n, "Command") { 98 | // Command type, event type hardcoded (could be improved) 99 | if strings.HasPrefix(n, "Create") { 100 | m.EventType = strings.Replace(n, "Create", "", 1) + "Created" 101 | } 102 | commands = append(commands, m) 103 | } else if strings.HasSuffix(n, "Created") || strings.HasSuffix(n, "Event") { 104 | events = append(events, m) 105 | } 106 | } 107 | 108 | // Write events.go 109 | var eventsBuf bytes.Buffer 110 | eventTemplate.Execute(&eventsBuf, map[string]any{ 111 | "Package": pkg, 112 | "Events": events, 113 | }) 114 | eventFile := file.GeneratedFilenamePrefix + "_events.go" 115 | g := plugin.NewGeneratedFile(eventFile, file.GoImportPath) 116 | g.P(eventsBuf.String()) 117 | 118 | // Write commands.go 119 | var commandsBuf bytes.Buffer 120 | commandTemplate.Execute(&commandsBuf, map[string]any{ 121 | "Package": pkg, 122 | "Commands": commands, 123 | }) 124 | cmdFile := file.GeneratedFilenamePrefix + "_commands.go" 125 | g2 := plugin.NewGeneratedFile(cmdFile, file.GoImportPath) 126 | g2.P(commandsBuf.String()) 127 | } 128 | return nil 129 | }) 130 | } 131 | 132 | func goType(f *protogen.Field) string { 133 | switch f.Desc.Kind() { 134 | case 1: // double 135 | return "float64" 136 | case 2: // float 137 | return "float32" 138 | case 3, 4, 5, 17, 18: // various ints 139 | return "int" 140 | case 9: 141 | return "string" 142 | case 8: 143 | return "bool" 144 | default: 145 | return "string" // default fallback 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /gen/ref.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc (unknown) 5 | // source: ref.proto 6 | 7 | package gen 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | _ "google.golang.org/protobuf/types/known/structpb" 13 | _ "google.golang.org/protobuf/types/known/timestamppb" 14 | reflect "reflect" 15 | sync "sync" 16 | unsafe "unsafe" 17 | ) 18 | 19 | const ( 20 | // Verify that this generated code is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 22 | // Verify that runtime/protoimpl is sufficiently up-to-date. 23 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 24 | ) 25 | 26 | type ParentRef struct { 27 | state protoimpl.MessageState `protogen:"open.v1"` 28 | AggregateType string `protobuf:"bytes,1,opt,name=aggregate_type,json=aggregateType,proto3" json:"aggregate_type,omitempty"` 29 | AggregateId string `protobuf:"bytes,2,opt,name=aggregate_id,json=aggregateId,proto3" json:"aggregate_id,omitempty"` 30 | unknownFields protoimpl.UnknownFields 31 | sizeCache protoimpl.SizeCache 32 | } 33 | 34 | func (x *ParentRef) Reset() { 35 | *x = ParentRef{} 36 | mi := &file_ref_proto_msgTypes[0] 37 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 38 | ms.StoreMessageInfo(mi) 39 | } 40 | 41 | func (x *ParentRef) String() string { 42 | return protoimpl.X.MessageStringOf(x) 43 | } 44 | 45 | func (*ParentRef) ProtoMessage() {} 46 | 47 | func (x *ParentRef) ProtoReflect() protoreflect.Message { 48 | mi := &file_ref_proto_msgTypes[0] 49 | if x != nil { 50 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 51 | if ms.LoadMessageInfo() == nil { 52 | ms.StoreMessageInfo(mi) 53 | } 54 | return ms 55 | } 56 | return mi.MessageOf(x) 57 | } 58 | 59 | // Deprecated: Use ParentRef.ProtoReflect.Descriptor instead. 60 | func (*ParentRef) Descriptor() ([]byte, []int) { 61 | return file_ref_proto_rawDescGZIP(), []int{0} 62 | } 63 | 64 | func (x *ParentRef) GetAggregateType() string { 65 | if x != nil { 66 | return x.AggregateType 67 | } 68 | return "" 69 | } 70 | 71 | func (x *ParentRef) GetAggregateId() string { 72 | if x != nil { 73 | return x.AggregateId 74 | } 75 | return "" 76 | } 77 | 78 | var File_ref_proto protoreflect.FileDescriptor 79 | 80 | const file_ref_proto_rawDesc = "" + 81 | "\n" + 82 | "\tref.proto\x12\x03gen\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"U\n" + 83 | "\tParentRef\x12%\n" + 84 | "\x0eaggregate_type\x18\x01 \x01(\tR\raggregateType\x12!\n" + 85 | "\faggregate_id\x18\x02 \x01(\tR\vaggregateIdB\"Z github.com/blinkinglight/bee/genb\x06proto3" 86 | 87 | var ( 88 | file_ref_proto_rawDescOnce sync.Once 89 | file_ref_proto_rawDescData []byte 90 | ) 91 | 92 | func file_ref_proto_rawDescGZIP() []byte { 93 | file_ref_proto_rawDescOnce.Do(func() { 94 | file_ref_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ref_proto_rawDesc), len(file_ref_proto_rawDesc))) 95 | }) 96 | return file_ref_proto_rawDescData 97 | } 98 | 99 | var file_ref_proto_msgTypes = make([]protoimpl.MessageInfo, 1) 100 | var file_ref_proto_goTypes = []any{ 101 | (*ParentRef)(nil), // 0: gen.ParentRef 102 | } 103 | var file_ref_proto_depIdxs = []int32{ 104 | 0, // [0:0] is the sub-list for method output_type 105 | 0, // [0:0] is the sub-list for method input_type 106 | 0, // [0:0] is the sub-list for extension type_name 107 | 0, // [0:0] is the sub-list for extension extendee 108 | 0, // [0:0] is the sub-list for field type_name 109 | } 110 | 111 | func init() { file_ref_proto_init() } 112 | func file_ref_proto_init() { 113 | if File_ref_proto != nil { 114 | return 115 | } 116 | type x struct{} 117 | out := protoimpl.TypeBuilder{ 118 | File: protoimpl.DescBuilder{ 119 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 120 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ref_proto_rawDesc), len(file_ref_proto_rawDesc)), 121 | NumEnums: 0, 122 | NumMessages: 1, 123 | NumExtensions: 0, 124 | NumServices: 0, 125 | }, 126 | GoTypes: file_ref_proto_goTypes, 127 | DependencyIndexes: file_ref_proto_depIdxs, 128 | MessageInfos: file_ref_proto_msgTypes, 129 | }.Build() 130 | File_ref_proto = out.File 131 | file_ref_proto_goTypes = nil 132 | file_ref_proto_depIdxs = nil 133 | } 134 | -------------------------------------------------------------------------------- /replay.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/blinkinglight/bee/gen" 11 | "github.com/blinkinglight/bee/ro" 12 | "github.com/nats-io/nats.go" 13 | "google.golang.org/protobuf/proto" 14 | ) 15 | 16 | const DeliverAll = 0 17 | 18 | // interface 19 | type ReplayHandler interface { 20 | ApplyEvent(m *gen.EventEnvelope) error 21 | } 22 | 23 | // Replay replays events for a given aggregate and aggregate ID. 24 | // accepts ro.Options to configure the replay behavior. 25 | // ro.WithAggregate ro.WithAggregateID - configure the aggregate and aggregate ID 26 | // ro.WithSubject - use custom subject instead of default "events.aggregate.aggregateID.>" 27 | // ro.WithStartSeq - start from event (if you have snapshot) 28 | // ro.WtihParent - nests subjects 29 | // ro.WithTimeout - timeout if no events for stream 30 | func Replay(ctx context.Context, fn ReplayHandler, opts ...ro.Options) error { 31 | 32 | cfg := &ro.Config{ 33 | StartSeq: DeliverAll, 34 | Timeout: 50 * time.Millisecond, 35 | } 36 | 37 | for _, opt := range opts { 38 | opt(cfg) 39 | } 40 | 41 | subject := fmt.Sprintf("%s.%s.%s.>", EventsPrefix, cfg.Aggregate, cfg.AggregateID) 42 | if len(cfg.Parents) > 0 { 43 | cfg.Subject = fmt.Sprintf("%s.%s.%s.%s.>", EventsPrefix, strings.Join(cfg.Parents, "."), cfg.Aggregate, cfg.AggregateID) 44 | } 45 | 46 | if cfg.Subject != "" { 47 | subject = cfg.Subject 48 | } 49 | 50 | lctx, cancel := context.WithCancel(ctx) 51 | js, _ := JetStream(ctx) 52 | 53 | msgs := make(chan *nats.Msg, 128) 54 | opt := nats.DeliverAll() 55 | if cfg.StartSeq > 0 { 56 | opt = nats.StartSequence(cfg.StartSeq) 57 | } 58 | sub, err := js.Subscribe(subject, func(msg *nats.Msg) { 59 | select { 60 | case <-lctx.Done(): 61 | return 62 | case msgs <- msg: 63 | } 64 | 65 | }, opt, nats.ManualAck()) 66 | if err != nil { 67 | cancel() 68 | return fmt.Errorf("projector: failed to subscribe to subject %s: %w", subject, err) 69 | } 70 | num, err := sub.InitialConsumerPending() 71 | if err != nil { 72 | cancel() 73 | return fmt.Errorf("projector: failed to get max pending for subject %s: %w", subject, err) 74 | } 75 | _ = num 76 | if num <= 0 { 77 | cancel() 78 | return fmt.Errorf("projector: no events found for subject %s", subject) 79 | } 80 | 81 | defer close(msgs) 82 | defer sub.Unsubscribe() 83 | 84 | go func() { 85 | n := uint64(0) 86 | for { 87 | select { 88 | case <-ctx.Done(): 89 | cancel() 90 | return 91 | case msg := <-msgs: 92 | if msg == nil { 93 | continue 94 | } 95 | n++ 96 | 97 | var event = &gen.EventEnvelope{} 98 | if err := proto.Unmarshal(msg.Data, event); err != nil { 99 | _ = err 100 | } 101 | 102 | fn.ApplyEvent(event) 103 | msg.Ack() 104 | if n == num { 105 | cancel() 106 | return 107 | } 108 | } 109 | } 110 | }() 111 | <-lctx.Done() 112 | return nil 113 | } 114 | 115 | // ReplayAndSubscribe replays events for a given aggregate and aggregate ID, 116 | // and subscribes to new events. 117 | // It accepts ro.Options to configure the replay behavior. 118 | // ro.WithAggregate ro.WithAggregateID - configure the aggregate and aggregate ID 119 | // ro.WithSubject - use custom subject instead of default "events.aggregate.aggregateID.>" 120 | // ro.WithStartSeq - start from event (if you have snapshot) 121 | // ro.WtihParent - nests subjects 122 | // ro.WithTimeout - timeout if no events for stream 123 | func ReplayAndSubscribe[T EventApplier](ctx context.Context, agg T, opts ...ro.Options) <-chan T { 124 | cfg := &ro.Config{ 125 | StartSeq: DeliverAll, 126 | } 127 | 128 | for _, opt := range opts { 129 | opt(cfg) 130 | } 131 | 132 | subject := fmt.Sprintf("%s.%s.%s.>", EventsPrefix, cfg.Aggregate, cfg.AggregateID) 133 | if cfg.Subject != "" { 134 | subject = cfg.Subject 135 | } 136 | 137 | ch := make(chan T, 128) 138 | msgs := make(chan *nats.Msg, 128) 139 | js, _ := JetStream(ctx) 140 | sub, err := js.Subscribe(subject, func(msg *nats.Msg) { 141 | msgs <- msg 142 | }, nats.ManualAck(), nats.DeliverNew()) 143 | if err != nil { 144 | log.Printf("ReplayAndSubscribe: Error subscribing to %s.%s: %v", cfg.Aggregate, cfg.AggregateID, err) 145 | ch <- agg 146 | close(ch) 147 | return ch 148 | } 149 | Replay(ctx, agg, opts...) 150 | ch <- agg 151 | go func() { 152 | defer sub.Unsubscribe() 153 | defer close(ch) 154 | defer close(msgs) 155 | for { 156 | select { 157 | case <-ctx.Done(): 158 | return 159 | case msg := <-msgs: 160 | event := &gen.EventEnvelope{} 161 | if err := proto.Unmarshal(msg.Data, event); err != nil { 162 | msg.Respond([]byte(err.Error())) 163 | msg.Ack() 164 | continue 165 | } 166 | agg.ApplyEvent(event) 167 | ch <- agg 168 | msg.Ack() 169 | } 170 | } 171 | }() 172 | return ch 173 | } 174 | -------------------------------------------------------------------------------- /eventsregistry.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/blinkinglight/bee/gen" 8 | ) 9 | 10 | var ( 11 | eventsRegistry = make(map[string]map[string]func() any, 0) 12 | commandsRegistry = make(map[string]map[string]func() any, 0) 13 | ) 14 | 15 | // RegisterEvent registers an event type for a specific aggregate. 16 | // The type T should be a struct that represents the event. 17 | // It creates a function that returns a new instance of T when called. 18 | // This allows for dynamic creation of event instances based on the aggregate and event type. 19 | func RegisterEvent[T any](aggreate, event string) { 20 | if _, ok := eventsRegistry[aggreate]; !ok { 21 | eventsRegistry[aggreate] = make(map[string]func() any, 0) 22 | } 23 | eventsRegistry[aggreate][event] = func() any { 24 | return new(T) 25 | } 26 | } 27 | 28 | // GetEvent retrieves an event instance based on the aggregate type and event type. 29 | // It checks if the aggregate and event are registered, and if so, it calls the corresponding 30 | // function to create a new instance of the event type. 31 | // If the aggregate or event is not registered, it returns nil. 32 | // This function is useful for dynamically handling events in a type-safe manner. 33 | func GetEvent(aggreate, event string) any { 34 | if aggreate == "" || event == "" { 35 | return nil 36 | } 37 | 38 | if aggreateEvents, ok := eventsRegistry[aggreate]; ok { 39 | if eventFn, ok := aggreateEvents[event]; ok { 40 | return eventFn() 41 | } 42 | } 43 | 44 | return nil 45 | } 46 | 47 | // UnmarshalEvent unmarshals a JSON payload into an event instance. 48 | // It takes an EventEnvelope, checks if it has valid event type and aggregate type, 49 | // and retrieves the corresponding event instance using GetEvent. 50 | // If the event type or aggregate type is not registered, it returns nil. 51 | // If the event instance is found, it unmarshals the JSON payload into that instance. 52 | // If unmarshaling fails, it returns an error. 53 | // This function is useful for converting raw event data into structured event types. 54 | func UnmarshalEvent(e *gen.EventEnvelope) (any, error) { 55 | if e == nil || e.EventType == "" || e.AggregateType == "" { 56 | return nil, fmt.Errorf("invalid event envelope: %v %v", e.EventType, e.AggregateType) 57 | } 58 | 59 | event := GetEvent(e.AggregateType, e.EventType) 60 | if event == nil { 61 | return nil, fmt.Errorf("event type %s for aggregate %s not registered", e.EventType, e.AggregateType) 62 | } 63 | 64 | if err := json.Unmarshal(e.Payload, event); err != nil { 65 | return nil, err 66 | } 67 | 68 | return event, nil 69 | } 70 | 71 | // RegisterCommand registers a command type for a specific aggregate. 72 | // The type T should be a struct that represents the command. 73 | // It creates a function that returns a new instance of T when called. 74 | // This allows for dynamic creation of command instances based on the aggregate and command type. 75 | func RegisterCommand[T any](aggreate, command string) { 76 | if _, ok := commandsRegistry[aggreate]; !ok { 77 | commandsRegistry[aggreate] = make(map[string]func() any, 0) 78 | } 79 | commandsRegistry[aggreate][command] = func() any { 80 | return new(T) 81 | } 82 | } 83 | 84 | // GetCommand retrieves a command instance based on the aggregate type and command type. 85 | // It checks if the aggregate and command are registered, and if so, it calls the corresponding 86 | // function to create a new instance of the command type. 87 | // If the aggregate or command is not registered, it returns nil. 88 | // This function is useful for dynamically handling commands in a type-safe manner. 89 | func GetCommand(aggreate, command string) any { 90 | if aggreate == "" || command == "" { 91 | return nil 92 | } 93 | 94 | if aggreateCommands, ok := commandsRegistry[aggreate]; ok { 95 | if commandFn, ok := aggreateCommands[command]; ok { 96 | return commandFn() 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | // UnmarshalCommand unmarshals a JSON payload into a command instance. 104 | // It takes a CommandEnvelope, checks if it has valid command type and aggregate type, 105 | // and retrieves the corresponding command instance using GetCommand. 106 | // If the command type or aggregate type is not registered, it returns nil. 107 | // If the command instance is found, it unmarshals the JSON payload into that instance. 108 | // If unmarshaling fails, it returns an error. 109 | // This function is useful for converting raw command data into structured command types. 110 | func UnmarshalCommand(c *gen.CommandEnvelope) (any, error) { 111 | if c == nil || c.CommandType == "" || c.Aggregate == "" { 112 | return nil, nil 113 | } 114 | 115 | command := GetCommand(c.Aggregate, c.CommandType) 116 | if command == nil { 117 | return nil, nil 118 | } 119 | 120 | if err := json.Unmarshal(c.Payload, command); err != nil { 121 | return nil, err 122 | } 123 | 124 | return command, nil 125 | } 126 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package bee 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/blinkinglight/bee/co" 11 | "github.com/blinkinglight/bee/gen" 12 | "github.com/nats-io/nats.go" 13 | "google.golang.org/protobuf/proto" 14 | ) 15 | 16 | const commandsStream = "COMMANDS" 17 | 18 | type CommandHandler interface { 19 | Handle(m *gen.CommandEnvelope) ([]*gen.EventEnvelope, error) 20 | } 21 | 22 | type CommandProcessor struct { 23 | js nats.JetStreamContext 24 | nc *nats.Conn 25 | aggregate string 26 | subject string 27 | durable string 28 | handler CommandHandler 29 | cfg *co.Config 30 | } 31 | 32 | // Command is the main entry point for processing commands. 33 | // accepts co.Options to configure the command processor. 34 | // co.WithSubject - use custom subject instead of default "cmds.aggregate" 35 | // co.WithAggregate - use custom aggregate name 36 | func Command(ctx context.Context, handler CommandHandler, opts ...co.Options) { 37 | cfg := &co.Config{} 38 | 39 | for _, opt := range opts { 40 | opt(cfg) 41 | } 42 | 43 | if cfg.Aggregate == "" { 44 | panic("Aggregate name is required for command processor") 45 | } 46 | 47 | subject := fmt.Sprintf(CommandsPrefix+".%s", cfg.Aggregate) 48 | if cfg.Subject != "" { 49 | subject = cfg.Subject 50 | } 51 | 52 | nc, _ := Nats(ctx) 53 | js, _ := JetStream(ctx) 54 | cp := &CommandProcessor{js: js, nc: nc, subject: subject, aggregate: cfg.Aggregate, durable: "default", handler: handler, cfg: cfg} 55 | c, cancel := context.WithCancel(ctx) 56 | go func() { 57 | for { 58 | err := cp.init(c, cancel) 59 | if err != nil { 60 | log.Printf("Error initializing command processor: %v", err) 61 | continue 62 | } 63 | } 64 | }() 65 | <-c.Done() 66 | } 67 | 68 | func (cp *CommandProcessor) init(ctx context.Context, cancel context.CancelFunc) error { 69 | 70 | _, err := cp.js.AddStream(&nats.StreamConfig{ 71 | Name: commandsStream, 72 | Subjects: []string{CommandsPrefix + ".>"}, 73 | Retention: nats.WorkQueuePolicy, 74 | Storage: nats.FileStorage, 75 | Replicas: 1, 76 | Duplicates: 5 * time.Minute, 77 | }) 78 | 79 | if err != nil { 80 | log.Printf("Error adding stream: %v", err) 81 | } 82 | 83 | _, err = cp.js.AddConsumer(commandsStream, &nats.ConsumerConfig{ 84 | Name: cp.aggregate + "_" + cp.durable + "_cmd", 85 | Durable: cp.aggregate + "_" + cp.durable + "_cmd", 86 | FilterSubject: cp.subject, 87 | AckPolicy: nats.AckExplicitPolicy, 88 | }) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | sub, err := cp.js.PullSubscribe(cp.subject, cp.aggregate+"_"+cp.durable+"_cmd", nats.BindStream(commandsStream), nats.ManualAck(), nats.AckExplicit(), nats.DeliverAll()) 94 | 95 | if err != nil { 96 | log.Printf("Error subscribing to commands: %v", err) 97 | cancel() 98 | } 99 | defer sub.Unsubscribe() 100 | 101 | for { 102 | if ctx.Err() != nil { 103 | cancel() 104 | return ctx.Err() 105 | } 106 | msg, _ := sub.Fetch(1, nats.MaxWait(60*time.Second)) 107 | if len(msg) == 0 { 108 | time.Sleep(50 * time.Millisecond) 109 | continue 110 | } 111 | for _, msg := range msg { 112 | var cmd gen.CommandEnvelope 113 | err := proto.Unmarshal(msg.Data, &cmd) 114 | if err != nil { 115 | log.Printf("Error unmarshalling message: %v", err) 116 | msg.Ack() 117 | continue 118 | } 119 | 120 | errorNotificationSubject := fmt.Sprintf("notifications.%s.error", cmd.CorrelationId) 121 | successNotificationSubject := fmt.Sprintf("notifications.%s.success", cmd.CorrelationId) 122 | 123 | events, err := cp.handler.Handle(&cmd) 124 | if err != nil { 125 | log.Printf("Error handling command: %v", err) 126 | if cmd.CorrelationId != "" { 127 | cp.nc.Publish(errorNotificationSubject, []byte(`{"message":"`+err.Error()+`"}`)) 128 | } 129 | msg.Ack() 130 | continue 131 | } 132 | 133 | for _, event := range events { 134 | if event.AggregateType == "" { 135 | event.AggregateType = cmd.Aggregate 136 | } 137 | if event.AggregateId == "" { 138 | event.AggregateId = cmd.AggregateId 139 | } 140 | eventSubject := fmt.Sprintf("events.%s.%s.%s", event.AggregateType, event.AggregateId, event.EventType) 141 | if len(event.Parents) > 0 { 142 | var parents []string 143 | for _, parent := range event.Parents { 144 | parents = append(parents, fmt.Sprintf("%s.%s", parent.AggregateType, parent.AggregateId)) 145 | } 146 | eventSubject = fmt.Sprintf("events.%s.%s.%s.%s", strings.Join(parents, "."), event.AggregateType, event.AggregateId, event.EventType) 147 | } 148 | 149 | b, _ := proto.Marshal(event) 150 | if _, err := cp.js.Publish(eventSubject, b); err != nil { 151 | log.Printf("Error publishing event %v", err) 152 | } 153 | } 154 | 155 | if cmd.CorrelationId != "" { 156 | cp.nc.Publish(successNotificationSubject, []byte(`{"message":"`+cmd.AggregateId+`"}`)) 157 | } 158 | _ = msg.Ack() 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /gen/query.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc (unknown) 5 | // source: query.proto 6 | 7 | package gen 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | structpb "google.golang.org/protobuf/types/known/structpb" 13 | reflect "reflect" 14 | sync "sync" 15 | unsafe "unsafe" 16 | ) 17 | 18 | const ( 19 | // Verify that this generated code is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 21 | // Verify that runtime/protoimpl is sufficiently up-to-date. 22 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 23 | ) 24 | 25 | type QueryEnvelope struct { 26 | state protoimpl.MessageState `protogen:"open.v1"` 27 | QueryType string `protobuf:"bytes,1,opt,name=query_type,json=queryType,proto3" json:"query_type,omitempty"` 28 | Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` 29 | ExtraMetadata *structpb.Struct `protobuf:"bytes,3,opt,name=extra_metadata,json=extraMetadata,proto3" json:"extra_metadata,omitempty"` 30 | TenantId string `protobuf:"bytes,4,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` 31 | CorrelationId string `protobuf:"bytes,5,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` 32 | UserId string `protobuf:"bytes,6,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` 33 | unknownFields protoimpl.UnknownFields 34 | sizeCache protoimpl.SizeCache 35 | } 36 | 37 | func (x *QueryEnvelope) Reset() { 38 | *x = QueryEnvelope{} 39 | mi := &file_query_proto_msgTypes[0] 40 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 41 | ms.StoreMessageInfo(mi) 42 | } 43 | 44 | func (x *QueryEnvelope) String() string { 45 | return protoimpl.X.MessageStringOf(x) 46 | } 47 | 48 | func (*QueryEnvelope) ProtoMessage() {} 49 | 50 | func (x *QueryEnvelope) ProtoReflect() protoreflect.Message { 51 | mi := &file_query_proto_msgTypes[0] 52 | if x != nil { 53 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 54 | if ms.LoadMessageInfo() == nil { 55 | ms.StoreMessageInfo(mi) 56 | } 57 | return ms 58 | } 59 | return mi.MessageOf(x) 60 | } 61 | 62 | // Deprecated: Use QueryEnvelope.ProtoReflect.Descriptor instead. 63 | func (*QueryEnvelope) Descriptor() ([]byte, []int) { 64 | return file_query_proto_rawDescGZIP(), []int{0} 65 | } 66 | 67 | func (x *QueryEnvelope) GetQueryType() string { 68 | if x != nil { 69 | return x.QueryType 70 | } 71 | return "" 72 | } 73 | 74 | func (x *QueryEnvelope) GetPayload() []byte { 75 | if x != nil { 76 | return x.Payload 77 | } 78 | return nil 79 | } 80 | 81 | func (x *QueryEnvelope) GetExtraMetadata() *structpb.Struct { 82 | if x != nil { 83 | return x.ExtraMetadata 84 | } 85 | return nil 86 | } 87 | 88 | func (x *QueryEnvelope) GetTenantId() string { 89 | if x != nil { 90 | return x.TenantId 91 | } 92 | return "" 93 | } 94 | 95 | func (x *QueryEnvelope) GetCorrelationId() string { 96 | if x != nil { 97 | return x.CorrelationId 98 | } 99 | return "" 100 | } 101 | 102 | func (x *QueryEnvelope) GetUserId() string { 103 | if x != nil { 104 | return x.UserId 105 | } 106 | return "" 107 | } 108 | 109 | var File_query_proto protoreflect.FileDescriptor 110 | 111 | const file_query_proto_rawDesc = "" + 112 | "\n" + 113 | "\vquery.proto\x12\x03gen\x1a\x1cgoogle/protobuf/struct.proto\"\xe5\x01\n" + 114 | "\rQueryEnvelope\x12\x1d\n" + 115 | "\n" + 116 | "query_type\x18\x01 \x01(\tR\tqueryType\x12\x18\n" + 117 | "\apayload\x18\x02 \x01(\fR\apayload\x12>\n" + 118 | "\x0eextra_metadata\x18\x03 \x01(\v2\x17.google.protobuf.StructR\rextraMetadata\x12\x1b\n" + 119 | "\ttenant_id\x18\x04 \x01(\tR\btenantId\x12%\n" + 120 | "\x0ecorrelation_id\x18\x05 \x01(\tR\rcorrelationId\x12\x17\n" + 121 | "\auser_id\x18\x06 \x01(\tR\x06userIdB\"Z github.com/blinkinglight/bee/genb\x06proto3" 122 | 123 | var ( 124 | file_query_proto_rawDescOnce sync.Once 125 | file_query_proto_rawDescData []byte 126 | ) 127 | 128 | func file_query_proto_rawDescGZIP() []byte { 129 | file_query_proto_rawDescOnce.Do(func() { 130 | file_query_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_query_proto_rawDesc), len(file_query_proto_rawDesc))) 131 | }) 132 | return file_query_proto_rawDescData 133 | } 134 | 135 | var file_query_proto_msgTypes = make([]protoimpl.MessageInfo, 1) 136 | var file_query_proto_goTypes = []any{ 137 | (*QueryEnvelope)(nil), // 0: gen.QueryEnvelope 138 | (*structpb.Struct)(nil), // 1: google.protobuf.Struct 139 | } 140 | var file_query_proto_depIdxs = []int32{ 141 | 1, // 0: gen.QueryEnvelope.extra_metadata:type_name -> google.protobuf.Struct 142 | 1, // [1:1] is the sub-list for method output_type 143 | 1, // [1:1] is the sub-list for method input_type 144 | 1, // [1:1] is the sub-list for extension type_name 145 | 1, // [1:1] is the sub-list for extension extendee 146 | 0, // [0:1] is the sub-list for field type_name 147 | } 148 | 149 | func init() { file_query_proto_init() } 150 | func file_query_proto_init() { 151 | if File_query_proto != nil { 152 | return 153 | } 154 | type x struct{} 155 | out := protoimpl.TypeBuilder{ 156 | File: protoimpl.DescBuilder{ 157 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 158 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_query_proto_rawDesc), len(file_query_proto_rawDesc)), 159 | NumEnums: 0, 160 | NumMessages: 1, 161 | NumExtensions: 0, 162 | NumServices: 0, 163 | }, 164 | GoTypes: file_query_proto_goTypes, 165 | DependencyIndexes: file_query_proto_depIdxs, 166 | MessageInfos: file_query_proto_msgTypes, 167 | }.Build() 168 | File_query_proto = out.File 169 | file_query_proto_goTypes = nil 170 | file_query_proto_depIdxs = nil 171 | } 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![tests](https://github.com/blinkinglight/bee/actions/workflows/go.yml/badge.svg) 3 | 4 | # bee - eventsourcing on nats.io 5 | 6 | Bee is a minimal Go library for implementing CQRS & Event-Sourcing using NATS JetStream as the transport and persistence layer. It offers clear abstractions with minimal dependencies and overhead. 7 | 8 | 9 | ![Bee](https://github.com/user-attachments/assets/660d9ae1-0c69-4263-9416-2b461bddcbd0) 10 | 11 | ## Why Bee? 12 | - Minimal Infrastructure: Leverages your existing NATS JetStream. 13 | - Type-Safe: Commands and events are protobuf-based. 14 | - Fast Replay: Efficiently rebuild aggregate states from event streams. 15 | - Small Footprint: Less than 1500 lines of simple, maintainable Go. 16 | 17 | 18 | ## Typical Use Cases 19 | 20 | Bee is great for small, event-centric scenarios: 21 | 22 | - Task-Oriented Microservices: Independent scaling of read/write sides. 23 | - Audit Trails & Ledgers: Immutable event history for compliance. 24 | - Sagas & Workflows: Event-driven state transitions. 25 | - Edge/IoT Deployments: Compact deployment on resource-limited devices. 26 | - Real-Time Game States: Fast catch-up of player states. 27 | - SaaS On-Prem Plugins: Easy local deployment without infrastructure complexity. 28 | - Ad-Hoc Analytics: Quickly spin up event projections. 29 | 30 | 31 | ## Table of Contents 32 | 33 | - [Getting Started](#getting-started) 34 | - [Installing](#installing) 35 | - [Settings](#settings) 36 | - [Interfaces](#interfaces) 37 | - [Functions](#functions) 38 | - [Options](#options) 39 | - [Usage](#usage) 40 | - [Example](#example) 41 | - [Prebuild examples](#prebuild-examples) 42 | - [Roadmap](#roadmap) 43 | - [Developing](#developing) 44 | - [License](#license) 45 | ## Getting Started 46 | 47 | ### Installing 48 | 49 | To start using `bee`, install Go and run `go get`: 50 | ```sh 51 | go get github.com/blinkinglight/bee 52 | ``` 53 | 54 | This will retrieve the library and update your `go.mod` and `go.sum` files. 55 | 56 | ### Settings 57 | 58 | ```go 59 | bee.EventsPrefix = "events" 60 | bee.CommandsPrefix = "cmds" 61 | bee.QueryPrefix = "query" 62 | ``` 63 | 64 | ### Interfaces 65 | 66 | ```go 67 | 68 | // Command handler 69 | type CommandHandler interface { 70 | Handle(m *gen.CommandEnvelope) ([]*gen.EventEnvelope, error) 71 | } 72 | 73 | // Projection handler 74 | type EventApplier interface { 75 | ApplyEvent(event *gen.EventEnvelope) error 76 | } 77 | 78 | // Query handler 79 | type Querier interface { 80 | Query(query *gen.QueryEnvelope) (interface{}, error) 81 | } 82 | 83 | // Replay handler 84 | type ReplayHandler interface { 85 | ApplyEvent(m *gen.EventEnvelope) error 86 | } 87 | 88 | // Event process manager handler 89 | type ManagerEventApplier interface { 90 | Handle(event *gen.EventEnvelope) ([]*gen.CommandEnvelope, error) 91 | } 92 | 93 | ``` 94 | 95 | 96 | ### Functions 97 | 98 | ```go 99 | func Command(ctx context.Context, handler CommandHandler, opts ...co.Options) 100 | func Project(ctx context.Context, fn EventApplier, opts ...po.Options) error 101 | func Query(ctx context.Context, fn Querier, opts ...qo.Options) error 102 | func Replay(ctx context.Context, fn ReplayHandler, opts ...ro.Options) 103 | func ReplayAndSubscribe[T EventApplier](ctx context.Context, agg T, opts ...ro.Options) <-chan T 104 | func Event(ctx context.Context, fn ManagerEventApplier, opts ...eo.Options) 105 | ``` 106 | 107 | ### Options 108 | 109 | Command options 110 | ```go 111 | func WithSubject(subject string) Options 112 | func WithAggreate(aggregate string) Options 113 | ``` 114 | 115 | Projection options 116 | ```go 117 | func WithSubject(subject string) Options 118 | func WithDurable(name string) Options 119 | func WithAggreate(aggregate string) Options 120 | func WithAggrateID(aggregateID string) Options 121 | func WithPrefix(prefix string) Options 122 | ``` 123 | 124 | Query options 125 | ```go 126 | func WithSubject(subject string) Options 127 | func WithAggreate(aggregate string) Options 128 | ``` 129 | 130 | Replay options 131 | ```go 132 | func WithEventType(eventType string) Options 133 | func WithParent(aggreate, id string) Options 134 | func WithSubject(subject string) Options 135 | func WithAggreate(aggregate string) Options 136 | func WithStartSeq(seq uint64) Options 137 | func WithAggregateID(id string) Options 138 | func WithTimeout(timeout time.Duration) Options 139 | ``` 140 | 141 | Event options 142 | ```go 143 | func WithSubject(subject string) 144 | func WithAggreate(aggregate string) 145 | func WithAggregateID(aggregateID string) 146 | func WithDurableName(durableName string) 147 | func WithPrefix(prefix string) 148 | ``` 149 | 150 | 151 | ## Usage: 152 | 153 | ```go 154 | ctx = bee.WithNats(ctx, nc) 155 | ctx = bee.WithJetStream(ctx, js) 156 | go bee.Command(ctx, NewService(), co.WithAggreate("users")) 157 | go bee.Project(ctx, NewUserProjection(), po.WithAggreate("users")) 158 | go bee.Query(ctx, NewUserProjection(), qo.WithAggreate("users")) 159 | go bee.Event(ctx, NewProcessManager(), eo.WithAggreate("users")) 160 | ``` 161 | 162 | ```go 163 | agg := NewAggregate(m.AggregateId) 164 | bee.Replay(ctx, agg, ro.WithAggreate(m.Aggregate), ro.WithAggregateID(m.AggregateId)) 165 | ``` 166 | 167 | ## Example 168 | 169 | ```go 170 | router.Get("/stream/{id}", func(w http.ResponseWriter, r *http.Request) { 171 | w.WriteHeader(200) 172 | id := chi.URLParam(r, "id") 173 | sse := datastar.NewSSE(w, r) 174 | _ = sse 175 | 176 | ctx := bee.WithJetStream(r.Context(), js) 177 | ctx = bee.WithNats(ctx, nc) 178 | 179 | agg := &Aggregate{} 180 | updates := bee.ReplayAndSubscribe(ctx, agg, ro.WithAggreate(users.Aggregate), ro.WithAggregateID(id)) 181 | for { 182 | select { 183 | case <-r.Context().Done(): 184 | return 185 | case update := <-updates: 186 | sse.MergeFragmentTempl(partials.History(update.History)) 187 | } 188 | } 189 | }) 190 | ``` 191 | 192 | and live projection aggrate: 193 | 194 | ```go 195 | type Aggregate struct { 196 | History []string 197 | } 198 | 199 | func (a *Aggregate) ApplyEvent(e *gen.EventEnvelope) error { 200 | event, err := bee.UnmarshalEvent(e) 201 | if err != nil { 202 | return fmt.Errorf("unmarshal event: %w", err) 203 | } 204 | switch event := event.(type) { 205 | case *users.UserCreated: 206 | a.History = append(a.History, "User created: "+event.Name+" from "+event.Country) 207 | case *users.UserUpdated: 208 | a.History = append(a.History, "User updated: "+event.Name+" from "+event.Country) 209 | case *users.UserNameChanged: 210 | a.History = append(a.History, "User name changed to: "+event.Name) 211 | default: 212 | log.Printf("unknown event type: %T", event) 213 | return nil // Ignore other event types 214 | } 215 | return nil 216 | } 217 | ``` 218 | 219 | ### Prebuild examples 220 | 221 | to run examples, first you need "nats server" to run with jetstream enabled. If you dont have one, first run this: 222 | 223 | `go run ./examples/natsserver` 224 | 225 | and then all other apps from examples: 226 | 227 | `go run ./examples/subscribers` 228 | 229 | and 230 | 231 | `go run ./examples/publishers` 232 | 233 | also 234 | 235 | `go run ./examples/query` 236 | 237 | 238 | ## Developing 239 | 240 | to work with this package you need 2 apps: 241 | 242 | `https://buf.build/docs/` and `go install google.golang.org/protobuf/cmd/protoc-gen-go@latest` 243 | 244 | 245 | ## Roadmap 246 | 247 | | Version | Planned Features | 248 | |---------|------------------------------------------------------| 249 | | v0.3 | Snapshots | 250 | | v1.0 | Stable API, full pkg.go.dev docs | 251 | 252 | ## Development & Contribution 253 | 254 | Pull requests are welcome! 255 | 256 | ## License 257 | 258 | Apache-2.0 © 2025 BlinkLight 259 | -------------------------------------------------------------------------------- /gen/event.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc (unknown) 5 | // source: event.proto 6 | 7 | package gen 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | structpb "google.golang.org/protobuf/types/known/structpb" 13 | timestamppb "google.golang.org/protobuf/types/known/timestamppb" 14 | reflect "reflect" 15 | sync "sync" 16 | unsafe "unsafe" 17 | ) 18 | 19 | const ( 20 | // Verify that this generated code is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 22 | // Verify that runtime/protoimpl is sufficiently up-to-date. 23 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 24 | ) 25 | 26 | type EventEnvelope struct { 27 | state protoimpl.MessageState `protogen:"open.v1"` 28 | EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` 29 | CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` 30 | Timestamp *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` 31 | AggregateType string `protobuf:"bytes,4,opt,name=aggregate_type,json=aggregateType,proto3" json:"aggregate_type,omitempty"` 32 | AggregateId string `protobuf:"bytes,5,opt,name=aggregate_id,json=aggregateId,proto3" json:"aggregate_id,omitempty"` 33 | EventType string `protobuf:"bytes,6,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` 34 | Payload []byte `protobuf:"bytes,7,opt,name=payload,proto3" json:"payload,omitempty"` 35 | UserId string `protobuf:"bytes,8,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` 36 | Metadata map[string]string `protobuf:"bytes,9,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` 37 | ExtraMetadata *structpb.Struct `protobuf:"bytes,10,opt,name=extra_metadata,json=extraMetadata,proto3" json:"extra_metadata,omitempty"` 38 | TenantId string `protobuf:"bytes,11,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` 39 | Parents []*ParentRef `protobuf:"bytes,12,rep,name=parents,proto3" json:"parents,omitempty"` 40 | unknownFields protoimpl.UnknownFields 41 | sizeCache protoimpl.SizeCache 42 | } 43 | 44 | func (x *EventEnvelope) Reset() { 45 | *x = EventEnvelope{} 46 | mi := &file_event_proto_msgTypes[0] 47 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 48 | ms.StoreMessageInfo(mi) 49 | } 50 | 51 | func (x *EventEnvelope) String() string { 52 | return protoimpl.X.MessageStringOf(x) 53 | } 54 | 55 | func (*EventEnvelope) ProtoMessage() {} 56 | 57 | func (x *EventEnvelope) ProtoReflect() protoreflect.Message { 58 | mi := &file_event_proto_msgTypes[0] 59 | if x != nil { 60 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 61 | if ms.LoadMessageInfo() == nil { 62 | ms.StoreMessageInfo(mi) 63 | } 64 | return ms 65 | } 66 | return mi.MessageOf(x) 67 | } 68 | 69 | // Deprecated: Use EventEnvelope.ProtoReflect.Descriptor instead. 70 | func (*EventEnvelope) Descriptor() ([]byte, []int) { 71 | return file_event_proto_rawDescGZIP(), []int{0} 72 | } 73 | 74 | func (x *EventEnvelope) GetEventId() string { 75 | if x != nil { 76 | return x.EventId 77 | } 78 | return "" 79 | } 80 | 81 | func (x *EventEnvelope) GetCorrelationId() string { 82 | if x != nil { 83 | return x.CorrelationId 84 | } 85 | return "" 86 | } 87 | 88 | func (x *EventEnvelope) GetTimestamp() *timestamppb.Timestamp { 89 | if x != nil { 90 | return x.Timestamp 91 | } 92 | return nil 93 | } 94 | 95 | func (x *EventEnvelope) GetAggregateType() string { 96 | if x != nil { 97 | return x.AggregateType 98 | } 99 | return "" 100 | } 101 | 102 | func (x *EventEnvelope) GetAggregateId() string { 103 | if x != nil { 104 | return x.AggregateId 105 | } 106 | return "" 107 | } 108 | 109 | func (x *EventEnvelope) GetEventType() string { 110 | if x != nil { 111 | return x.EventType 112 | } 113 | return "" 114 | } 115 | 116 | func (x *EventEnvelope) GetPayload() []byte { 117 | if x != nil { 118 | return x.Payload 119 | } 120 | return nil 121 | } 122 | 123 | func (x *EventEnvelope) GetUserId() string { 124 | if x != nil { 125 | return x.UserId 126 | } 127 | return "" 128 | } 129 | 130 | func (x *EventEnvelope) GetMetadata() map[string]string { 131 | if x != nil { 132 | return x.Metadata 133 | } 134 | return nil 135 | } 136 | 137 | func (x *EventEnvelope) GetExtraMetadata() *structpb.Struct { 138 | if x != nil { 139 | return x.ExtraMetadata 140 | } 141 | return nil 142 | } 143 | 144 | func (x *EventEnvelope) GetTenantId() string { 145 | if x != nil { 146 | return x.TenantId 147 | } 148 | return "" 149 | } 150 | 151 | func (x *EventEnvelope) GetParents() []*ParentRef { 152 | if x != nil { 153 | return x.Parents 154 | } 155 | return nil 156 | } 157 | 158 | var File_event_proto protoreflect.FileDescriptor 159 | 160 | const file_event_proto_rawDesc = "" + 161 | "\n" + 162 | "\vevent.proto\x12\x03gen\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\tref.proto\"\xa9\x04\n" + 163 | "\rEventEnvelope\x12\x19\n" + 164 | "\bevent_id\x18\x01 \x01(\tR\aeventId\x12%\n" + 165 | "\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x128\n" + 166 | "\ttimestamp\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12%\n" + 167 | "\x0eaggregate_type\x18\x04 \x01(\tR\raggregateType\x12!\n" + 168 | "\faggregate_id\x18\x05 \x01(\tR\vaggregateId\x12\x1d\n" + 169 | "\n" + 170 | "event_type\x18\x06 \x01(\tR\teventType\x12\x18\n" + 171 | "\apayload\x18\a \x01(\fR\apayload\x12\x17\n" + 172 | "\auser_id\x18\b \x01(\tR\x06userId\x12<\n" + 173 | "\bmetadata\x18\t \x03(\v2 .gen.EventEnvelope.MetadataEntryR\bmetadata\x12>\n" + 174 | "\x0eextra_metadata\x18\n" + 175 | " \x01(\v2\x17.google.protobuf.StructR\rextraMetadata\x12\x1b\n" + 176 | "\ttenant_id\x18\v \x01(\tR\btenantId\x12(\n" + 177 | "\aparents\x18\f \x03(\v2\x0e.gen.ParentRefR\aparents\x1a;\n" + 178 | "\rMetadataEntry\x12\x10\n" + 179 | "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + 180 | "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\"Z github.com/blinkinglight/bee/genb\x06proto3" 181 | 182 | var ( 183 | file_event_proto_rawDescOnce sync.Once 184 | file_event_proto_rawDescData []byte 185 | ) 186 | 187 | func file_event_proto_rawDescGZIP() []byte { 188 | file_event_proto_rawDescOnce.Do(func() { 189 | file_event_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_event_proto_rawDesc), len(file_event_proto_rawDesc))) 190 | }) 191 | return file_event_proto_rawDescData 192 | } 193 | 194 | var file_event_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 195 | var file_event_proto_goTypes = []any{ 196 | (*EventEnvelope)(nil), // 0: gen.EventEnvelope 197 | nil, // 1: gen.EventEnvelope.MetadataEntry 198 | (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp 199 | (*structpb.Struct)(nil), // 3: google.protobuf.Struct 200 | (*ParentRef)(nil), // 4: gen.ParentRef 201 | } 202 | var file_event_proto_depIdxs = []int32{ 203 | 2, // 0: gen.EventEnvelope.timestamp:type_name -> google.protobuf.Timestamp 204 | 1, // 1: gen.EventEnvelope.metadata:type_name -> gen.EventEnvelope.MetadataEntry 205 | 3, // 2: gen.EventEnvelope.extra_metadata:type_name -> google.protobuf.Struct 206 | 4, // 3: gen.EventEnvelope.parents:type_name -> gen.ParentRef 207 | 4, // [4:4] is the sub-list for method output_type 208 | 4, // [4:4] is the sub-list for method input_type 209 | 4, // [4:4] is the sub-list for extension type_name 210 | 4, // [4:4] is the sub-list for extension extendee 211 | 0, // [0:4] is the sub-list for field type_name 212 | } 213 | 214 | func init() { file_event_proto_init() } 215 | func file_event_proto_init() { 216 | if File_event_proto != nil { 217 | return 218 | } 219 | file_ref_proto_init() 220 | type x struct{} 221 | out := protoimpl.TypeBuilder{ 222 | File: protoimpl.DescBuilder{ 223 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 224 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_event_proto_rawDesc), len(file_event_proto_rawDesc)), 225 | NumEnums: 0, 226 | NumMessages: 2, 227 | NumExtensions: 0, 228 | NumServices: 0, 229 | }, 230 | GoTypes: file_event_proto_goTypes, 231 | DependencyIndexes: file_event_proto_depIdxs, 232 | MessageInfos: file_event_proto_msgTypes, 233 | }.Build() 234 | File_event_proto = out.File 235 | file_event_proto_goTypes = nil 236 | file_event_proto_depIdxs = nil 237 | } 238 | -------------------------------------------------------------------------------- /gen/command.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc (unknown) 5 | // source: command.proto 6 | 7 | package gen 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | structpb "google.golang.org/protobuf/types/known/structpb" 13 | timestamppb "google.golang.org/protobuf/types/known/timestamppb" 14 | reflect "reflect" 15 | sync "sync" 16 | unsafe "unsafe" 17 | ) 18 | 19 | const ( 20 | // Verify that this generated code is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 22 | // Verify that runtime/protoimpl is sufficiently up-to-date. 23 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 24 | ) 25 | 26 | type CommandEnvelope struct { 27 | state protoimpl.MessageState `protogen:"open.v1"` 28 | CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` 29 | CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` 30 | Timestamp *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` 31 | UserId string `protobuf:"bytes,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` 32 | Aggregate string `protobuf:"bytes,5,opt,name=aggregate,proto3" json:"aggregate,omitempty"` 33 | AggregateId string `protobuf:"bytes,6,opt,name=aggregate_id,json=aggregateId,proto3" json:"aggregate_id,omitempty"` 34 | CommandType string `protobuf:"bytes,7,opt,name=command_type,json=commandType,proto3" json:"command_type,omitempty"` 35 | Payload []byte `protobuf:"bytes,8,opt,name=payload,proto3" json:"payload,omitempty"` 36 | Metadata map[string]string `protobuf:"bytes,9,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` 37 | ExtraMetadata *structpb.Struct `protobuf:"bytes,10,opt,name=extra_metadata,json=extraMetadata,proto3" json:"extra_metadata,omitempty"` 38 | TenantId string `protobuf:"bytes,11,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` 39 | Parents []*ParentRef `protobuf:"bytes,12,rep,name=parents,proto3" json:"parents,omitempty"` 40 | unknownFields protoimpl.UnknownFields 41 | sizeCache protoimpl.SizeCache 42 | } 43 | 44 | func (x *CommandEnvelope) Reset() { 45 | *x = CommandEnvelope{} 46 | mi := &file_command_proto_msgTypes[0] 47 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 48 | ms.StoreMessageInfo(mi) 49 | } 50 | 51 | func (x *CommandEnvelope) String() string { 52 | return protoimpl.X.MessageStringOf(x) 53 | } 54 | 55 | func (*CommandEnvelope) ProtoMessage() {} 56 | 57 | func (x *CommandEnvelope) ProtoReflect() protoreflect.Message { 58 | mi := &file_command_proto_msgTypes[0] 59 | if x != nil { 60 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 61 | if ms.LoadMessageInfo() == nil { 62 | ms.StoreMessageInfo(mi) 63 | } 64 | return ms 65 | } 66 | return mi.MessageOf(x) 67 | } 68 | 69 | // Deprecated: Use CommandEnvelope.ProtoReflect.Descriptor instead. 70 | func (*CommandEnvelope) Descriptor() ([]byte, []int) { 71 | return file_command_proto_rawDescGZIP(), []int{0} 72 | } 73 | 74 | func (x *CommandEnvelope) GetCommandId() string { 75 | if x != nil { 76 | return x.CommandId 77 | } 78 | return "" 79 | } 80 | 81 | func (x *CommandEnvelope) GetCorrelationId() string { 82 | if x != nil { 83 | return x.CorrelationId 84 | } 85 | return "" 86 | } 87 | 88 | func (x *CommandEnvelope) GetTimestamp() *timestamppb.Timestamp { 89 | if x != nil { 90 | return x.Timestamp 91 | } 92 | return nil 93 | } 94 | 95 | func (x *CommandEnvelope) GetUserId() string { 96 | if x != nil { 97 | return x.UserId 98 | } 99 | return "" 100 | } 101 | 102 | func (x *CommandEnvelope) GetAggregate() string { 103 | if x != nil { 104 | return x.Aggregate 105 | } 106 | return "" 107 | } 108 | 109 | func (x *CommandEnvelope) GetAggregateId() string { 110 | if x != nil { 111 | return x.AggregateId 112 | } 113 | return "" 114 | } 115 | 116 | func (x *CommandEnvelope) GetCommandType() string { 117 | if x != nil { 118 | return x.CommandType 119 | } 120 | return "" 121 | } 122 | 123 | func (x *CommandEnvelope) GetPayload() []byte { 124 | if x != nil { 125 | return x.Payload 126 | } 127 | return nil 128 | } 129 | 130 | func (x *CommandEnvelope) GetMetadata() map[string]string { 131 | if x != nil { 132 | return x.Metadata 133 | } 134 | return nil 135 | } 136 | 137 | func (x *CommandEnvelope) GetExtraMetadata() *structpb.Struct { 138 | if x != nil { 139 | return x.ExtraMetadata 140 | } 141 | return nil 142 | } 143 | 144 | func (x *CommandEnvelope) GetTenantId() string { 145 | if x != nil { 146 | return x.TenantId 147 | } 148 | return "" 149 | } 150 | 151 | func (x *CommandEnvelope) GetParents() []*ParentRef { 152 | if x != nil { 153 | return x.Parents 154 | } 155 | return nil 156 | } 157 | 158 | var File_command_proto protoreflect.FileDescriptor 159 | 160 | const file_command_proto_rawDesc = "" + 161 | "\n" + 162 | "\rcommand.proto\x12\x03gen\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\tref.proto\"\xac\x04\n" + 163 | "\x0fCommandEnvelope\x12\x1d\n" + 164 | "\n" + 165 | "command_id\x18\x01 \x01(\tR\tcommandId\x12%\n" + 166 | "\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x128\n" + 167 | "\ttimestamp\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12\x17\n" + 168 | "\auser_id\x18\x04 \x01(\tR\x06userId\x12\x1c\n" + 169 | "\taggregate\x18\x05 \x01(\tR\taggregate\x12!\n" + 170 | "\faggregate_id\x18\x06 \x01(\tR\vaggregateId\x12!\n" + 171 | "\fcommand_type\x18\a \x01(\tR\vcommandType\x12\x18\n" + 172 | "\apayload\x18\b \x01(\fR\apayload\x12>\n" + 173 | "\bmetadata\x18\t \x03(\v2\".gen.CommandEnvelope.MetadataEntryR\bmetadata\x12>\n" + 174 | "\x0eextra_metadata\x18\n" + 175 | " \x01(\v2\x17.google.protobuf.StructR\rextraMetadata\x12\x1b\n" + 176 | "\ttenant_id\x18\v \x01(\tR\btenantId\x12(\n" + 177 | "\aparents\x18\f \x03(\v2\x0e.gen.ParentRefR\aparents\x1a;\n" + 178 | "\rMetadataEntry\x12\x10\n" + 179 | "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + 180 | "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\"Z github.com/blinkinglight/bee/genb\x06proto3" 181 | 182 | var ( 183 | file_command_proto_rawDescOnce sync.Once 184 | file_command_proto_rawDescData []byte 185 | ) 186 | 187 | func file_command_proto_rawDescGZIP() []byte { 188 | file_command_proto_rawDescOnce.Do(func() { 189 | file_command_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_command_proto_rawDesc), len(file_command_proto_rawDesc))) 190 | }) 191 | return file_command_proto_rawDescData 192 | } 193 | 194 | var file_command_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 195 | var file_command_proto_goTypes = []any{ 196 | (*CommandEnvelope)(nil), // 0: gen.CommandEnvelope 197 | nil, // 1: gen.CommandEnvelope.MetadataEntry 198 | (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp 199 | (*structpb.Struct)(nil), // 3: google.protobuf.Struct 200 | (*ParentRef)(nil), // 4: gen.ParentRef 201 | } 202 | var file_command_proto_depIdxs = []int32{ 203 | 2, // 0: gen.CommandEnvelope.timestamp:type_name -> google.protobuf.Timestamp 204 | 1, // 1: gen.CommandEnvelope.metadata:type_name -> gen.CommandEnvelope.MetadataEntry 205 | 3, // 2: gen.CommandEnvelope.extra_metadata:type_name -> google.protobuf.Struct 206 | 4, // 3: gen.CommandEnvelope.parents:type_name -> gen.ParentRef 207 | 4, // [4:4] is the sub-list for method output_type 208 | 4, // [4:4] is the sub-list for method input_type 209 | 4, // [4:4] is the sub-list for extension type_name 210 | 4, // [4:4] is the sub-list for extension extendee 211 | 0, // [0:4] is the sub-list for field type_name 212 | } 213 | 214 | func init() { file_command_proto_init() } 215 | func file_command_proto_init() { 216 | if File_command_proto != nil { 217 | return 218 | } 219 | file_ref_proto_init() 220 | type x struct{} 221 | out := protoimpl.TypeBuilder{ 222 | File: protoimpl.DescBuilder{ 223 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 224 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_command_proto_rawDesc), len(file_command_proto_rawDesc)), 225 | NumEnums: 0, 226 | NumMessages: 2, 227 | NumExtensions: 0, 228 | NumServices: 0, 229 | }, 230 | GoTypes: file_command_proto_goTypes, 231 | DependencyIndexes: file_command_proto_depIdxs, 232 | MessageInfos: file_command_proto_msgTypes, 233 | }.Build() 234 | File_command_proto = out.File 235 | file_command_proto_goTypes = nil 236 | file_command_proto_depIdxs = nil 237 | } 238 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /bee_test.go: -------------------------------------------------------------------------------- 1 | package bee_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/blinkinglight/bee" 10 | "github.com/blinkinglight/bee/co" 11 | "github.com/blinkinglight/bee/eo" 12 | "github.com/blinkinglight/bee/gen" 13 | "github.com/blinkinglight/bee/ro" 14 | "github.com/delaneyj/toolbelt/embeddednats" 15 | "github.com/nats-io/nats-server/v2/server" 16 | "github.com/nats-io/nats.go" 17 | "google.golang.org/protobuf/proto" 18 | ) 19 | 20 | func init() { 21 | bee.RegisterEvent[UserCreatedEvent]("users", "created") 22 | bee.RegisterEvent[UserUpdatedEvent]("users", "updated") 23 | bee.RegisterEvent[UserDeletedEvent]("users", "deleted") 24 | 25 | bee.RegisterEvent[TicketCreatedEvent]("tickets", "created") 26 | 27 | bee.RegisterCommand[CreateUserCommand]("users", "create") 28 | bee.RegisterCommand[UpdateUserCommand]("users", "update") 29 | bee.RegisterCommand[DeleteUserCommand]("users", "delete") 30 | } 31 | 32 | type UserCreatedEvent struct { 33 | Name string `json:"name"` 34 | Country string `json:"country"` 35 | } 36 | 37 | type UserUpdatedEvent struct { 38 | Name string `json:"name"` 39 | Country string `json:"country"` 40 | } 41 | type UserDeletedEvent struct { 42 | } 43 | 44 | type CreateUserCommand struct { 45 | Name string `json:"name"` 46 | Country string `json:"country"` 47 | } 48 | 49 | type UpdateUserCommand struct { 50 | Name string `json:"name"` 51 | Country string `json:"country"` 52 | } 53 | type DeleteUserCommand struct { 54 | } 55 | 56 | type MockReplayHandler struct { 57 | Name string 58 | Country string 59 | created bool 60 | } 61 | 62 | func (m *MockReplayHandler) ApplyEvent(e *gen.EventEnvelope) error { 63 | event, err := bee.UnmarshalEvent(e) 64 | if err != nil { 65 | return fmt.Errorf("failed to unmarshal event: %w", err) 66 | } 67 | switch event := event.(type) { 68 | case *UserCreatedEvent: 69 | if m.created { 70 | return fmt.Errorf("user already created") 71 | } 72 | m.created = true 73 | m.Name = event.Name 74 | m.Country = event.Country 75 | case *UserUpdatedEvent: 76 | m.Name = event.Name 77 | m.Country = event.Country 78 | default: 79 | return nil 80 | } 81 | return nil 82 | } 83 | 84 | func client() (*nats.Conn, func(), error) { 85 | server, err := embeddednats.New( 86 | context.Background(), 87 | embeddednats.WithShouldClearData(true), 88 | embeddednats.WithDirectory("./tmpdata"), 89 | embeddednats.WithNATSServerOptions(&server.Options{ 90 | JetStream: true, 91 | NoLog: false, 92 | Debug: true, 93 | Trace: true, 94 | TraceVerbose: true, 95 | Port: 4333, 96 | StoreDir: "./tmpdata", 97 | }), 98 | ) 99 | if err != nil { 100 | return nil, nil, err 101 | } 102 | server.WaitForServer() 103 | 104 | nc, err := server.Client() 105 | 106 | return nc, func() { 107 | nc.Close() 108 | server.Close() 109 | }, err 110 | } 111 | 112 | func TestReplay(t *testing.T) { 113 | 114 | nc, cleanup, err := client() 115 | if err != nil { 116 | t.Fatalf("Failed to create NATS client: %v", err) 117 | } 118 | defer cleanup() 119 | 120 | js, err := nc.JetStream() 121 | if err != nil { 122 | t.Fatalf("Failed to get JetStream context: %v", err) 123 | } 124 | js.DeleteStream("events") // Clean up any existing stream 125 | js.AddStream(&nats.StreamConfig{ 126 | Name: "events", 127 | Subjects: []string{"events.>"}, 128 | Storage: nats.MemoryStorage, 129 | }) 130 | 131 | ctx := bee.WithNats(t.Context(), nc) 132 | ctx = bee.WithJetStream(ctx, js) 133 | 134 | evt1 := &gen.EventEnvelope{ 135 | EventType: "created", 136 | AggregateType: "users", 137 | AggregateId: "123", 138 | Payload: []byte(`{"name": "John Doe", "country": "USA"}`), 139 | } 140 | b1, _ := proto.Marshal(evt1) 141 | _, err = js.Publish("events.users.123.created", b1) 142 | if err != nil { 143 | t.Fatalf("Failed to publish event: %v", err) 144 | } 145 | 146 | time.Sleep(100 * time.Millisecond) // Wait for events to be processed 147 | evt2 := &gen.EventEnvelope{ 148 | EventType: "updated", 149 | AggregateType: "users", 150 | AggregateId: "123", 151 | Payload: []byte(`{"name": "John Smith", "country": "Canada"}`), 152 | } 153 | b2, _ := proto.Marshal(evt2) 154 | _, err = js.Publish("events.users.123.updated", b2) 155 | if err != nil { 156 | t.Fatalf("Failed to publish event: %v", err) 157 | } 158 | time.Sleep(100 * time.Millisecond) // Wait for events to be processed 159 | replayHandler := &MockReplayHandler{} 160 | bee.Replay(ctx, replayHandler, ro.WithAggreate("users"), ro.WithAggregateID("*")) 161 | 162 | if replayHandler.Name != "John Smith" { 163 | t.Errorf("Expected name to be 'John Smith', got '%s'", replayHandler.Name) 164 | } 165 | if replayHandler.Country != "Canada" { 166 | t.Errorf("Expected country to be 'Canada', got '%s'", replayHandler.Country) 167 | } 168 | } 169 | 170 | func TestCommand(t *testing.T) { 171 | nc, cleanup, err := client() 172 | if err != nil { 173 | t.Fatalf("Failed to create NATS client: %v", err) 174 | } 175 | defer cleanup() 176 | 177 | js, err := nc.JetStream() 178 | if err != nil { 179 | t.Fatalf("Failed to get JetStream context: %v", err) 180 | } 181 | js.DeleteStream("cmds") // Clean up any existing stream 182 | js.DeleteStream("events") // Clean up any existing stream 183 | js.AddStream(&nats.StreamConfig{ 184 | Name: "events", 185 | Subjects: []string{"events.>"}, 186 | Storage: nats.MemoryStorage, 187 | }) 188 | ctx := bee.WithNats(context.Background(), nc) 189 | ctx = bee.WithJetStream(ctx, js) 190 | go bee.Command(ctx, New(ctx), co.WithAggreate("users")) 191 | time.Sleep(100 * time.Millisecond) // Give some time for the command handler to start 192 | // service := New(js) 193 | // err = bee.Register(context.Background(), "users", service.Handle) 194 | cmd1 := &gen.CommandEnvelope{ 195 | CommandType: "create", 196 | AggregateId: "321", 197 | Aggregate: "users", 198 | Payload: []byte(`{"name": "John Doe", "country": "USA"}`), 199 | } 200 | b1, _ := proto.Marshal(cmd1) 201 | _, err = js.Publish("cmds.users", b1) 202 | if err != nil { 203 | t.Fatalf("Failed to publish command: %v", err) 204 | } 205 | time.Sleep(100 * time.Millisecond) // Wait for command processing 206 | cmd2 := &gen.CommandEnvelope{ 207 | CommandType: "update", 208 | AggregateId: "321", 209 | Aggregate: "users", 210 | Payload: []byte(`{"name": "John Doe", "country": "Canada"}`), 211 | } 212 | 213 | b2, _ := proto.Marshal(cmd2) 214 | _, err = js.Publish("cmds.users", b2) 215 | if err != nil { 216 | t.Fatalf("Failed to publish command: %v", err) 217 | } 218 | 219 | time.Sleep(100 * time.Millisecond) // Wait for command processing 220 | 221 | replayHandler := NewAggregate("321") 222 | bee.Replay(ctx, replayHandler, ro.WithAggreate("users"), ro.WithAggregateID("321")) 223 | 224 | if replayHandler.Name != "John Doe" { 225 | t.Errorf("Expected name to be 'John Doe', got '%s'", replayHandler.Name) 226 | } 227 | if replayHandler.Country != "Canada" { 228 | t.Errorf("Expected country to be 'Canada', got '%s'", replayHandler.Country) 229 | } 230 | 231 | evt := &gen.EventEnvelope{ 232 | EventType: "created", 233 | AggregateType: "tickets", 234 | AggregateId: "1", 235 | Payload: []byte(`{"name": "Project 1", "description": "This is a test project"}`), 236 | Parents: []*gen.ParentRef{{AggregateType: "projects", AggregateId: "1"}}, 237 | } 238 | b, _ := proto.Marshal(evt) 239 | js.Publish("events.projects.1.tickets.1.created", b) 240 | 241 | var projectAgg = &TicketsAggregate{} 242 | bee.Replay(ctx, projectAgg, ro.WithAggreate("tickets"), ro.WithAggregateID("1"), ro.WithParent("projects", "1")) 243 | if len(projectAgg.List) == 0 { 244 | t.Errorf("Expected tickets list to contain at least one item, got %v", projectAgg.List) 245 | } 246 | if projectAgg.List[0] != "Project 1" { 247 | t.Errorf("Expected tickets list to contain 'Project 1', got %v", projectAgg.List) 248 | } 249 | } 250 | 251 | type TicketCreatedEvent struct { 252 | Name string `json:"name"` 253 | } 254 | 255 | type TicketsAggregate struct { 256 | List []string 257 | } 258 | 259 | func (t *TicketsAggregate) ApplyEvent(e *gen.EventEnvelope) error { 260 | event, err := bee.UnmarshalEvent(e) 261 | if err != nil { 262 | return fmt.Errorf("failed to unmarshal event: %w", err) 263 | } 264 | switch event := event.(type) { 265 | case *TicketCreatedEvent: 266 | t.List = append(t.List, event.Name) 267 | } 268 | return nil 269 | } 270 | 271 | func New(ctx context.Context) *UserServiceTest { 272 | js, _ := bee.JetStream(ctx) 273 | 274 | return &UserServiceTest{ 275 | js: js, 276 | ctx: ctx, 277 | } 278 | } 279 | 280 | type UserServiceTest struct { 281 | js nats.JetStreamContext 282 | ctx context.Context 283 | } 284 | 285 | func (s UserServiceTest) Handle(m *gen.CommandEnvelope) ([]*gen.EventEnvelope, error) { 286 | 287 | agg := NewAggregate(m.AggregateId) 288 | bee.Replay(s.ctx, agg, ro.WithAggreate(m.Aggregate), ro.WithAggregateID(m.AggregateId)) 289 | return agg.ApplyCommand(m) 290 | } 291 | 292 | // --- UserAggregateTest implements ES aggregate logic --- 293 | type UserAggregateTest struct { 294 | ID string 295 | Name string 296 | Country string 297 | Deleted bool 298 | created bool 299 | } 300 | 301 | type User struct { 302 | Name string `json:"name"` 303 | Country string `json:"country"` 304 | } 305 | 306 | func NewAggregate(id string) *UserAggregateTest { 307 | return &UserAggregateTest{ID: id} 308 | } 309 | 310 | func (u *UserAggregateTest) ApplyEvent(e *gen.EventEnvelope) error { 311 | event, err := bee.UnmarshalEvent(e) 312 | if err != nil { 313 | return fmt.Errorf("failed to unmarshal event: %w", err) 314 | } 315 | switch event := event.(type) { 316 | case *UserCreatedEvent: 317 | if u.created { 318 | return fmt.Errorf("user already created") 319 | } 320 | u.created = true 321 | u.Name = event.Name 322 | u.Country = event.Country 323 | u.Deleted = false 324 | case *UserUpdatedEvent: 325 | u.Country = event.Country 326 | u.Name = event.Name 327 | case *UserDeletedEvent: 328 | u.Deleted = true 329 | } 330 | return nil 331 | } 332 | 333 | func (u *UserAggregateTest) ApplyCommand(c *gen.CommandEnvelope) ([]*gen.EventEnvelope, error) { 334 | if c.AggregateId != u.ID { 335 | return nil, fmt.Errorf("aggregate ID mismatch") 336 | } 337 | var event *gen.EventEnvelope = &gen.EventEnvelope{AggregateId: u.ID} 338 | event.AggregateType = "users" 339 | event.AggregateId = c.AggregateId 340 | 341 | command, err := bee.UnmarshalCommand(c) 342 | if err != nil { 343 | return nil, fmt.Errorf("failed to unmarshal command: %w", err) 344 | } 345 | 346 | switch command.(type) { 347 | case *CreateUserCommand: 348 | event.EventType = "created" 349 | event.Payload = c.Payload 350 | case *UpdateUserCommand: 351 | if u.Deleted { 352 | return nil, fmt.Errorf("cannot update deleted user") 353 | } 354 | event.EventType = "updated" 355 | event.Payload = c.Payload 356 | case *DeleteUserCommand: 357 | if u.Deleted { 358 | return nil, fmt.Errorf("user already deleted") 359 | } 360 | event.EventType = "deleted" 361 | default: 362 | return nil, fmt.Errorf("unknown command type: %s", c.CommandType) 363 | } 364 | return []*gen.EventEnvelope{event}, nil 365 | } 366 | 367 | func TestEvent(t *testing.T) { 368 | nc, cleanup, err := client() 369 | 370 | if err != nil { 371 | t.Fatalf("Failed to create NATS client: %v", err) 372 | } 373 | defer cleanup() 374 | 375 | js, err := nc.JetStream() 376 | if err != nil { 377 | t.Fatalf("Failed to get JetStream context: %v", err) 378 | } 379 | js.DeleteStream("events") // Clean up any existing stream 380 | js.AddStream(&nats.StreamConfig{ 381 | Name: "EVENTS", 382 | Subjects: []string{"events" + ".>"}, 383 | Storage: nats.FileStorage, 384 | Retention: nats.LimitsPolicy, 385 | MaxAge: 0, 386 | Replicas: 1, 387 | }) 388 | ctx := bee.WithNats(t.Context(), nc) 389 | ctx = bee.WithJetStream(ctx, js) 390 | 391 | c1 := &cmd1{} 392 | go bee.Command(ctx, c1, co.WithAggreate("users")) 393 | go bee.Event(ctx, &EventProMgr{}, eo.WithAggreate("users")) 394 | 395 | time.Sleep(100 * time.Millisecond) // Give some time for the event handler to start 396 | 397 | evt1 := &gen.EventEnvelope{ 398 | EventType: "created", 399 | AggregateType: "users", 400 | AggregateId: "123", 401 | Payload: []byte(`{"name": "John Doe", "country": "USA"}`), 402 | } 403 | b1, _ := proto.Marshal(evt1) 404 | _, err = js.Publish("events.users.123.created", b1) 405 | if err != nil { 406 | t.Fatalf("Failed to publish event: %v", err) 407 | } 408 | 409 | cm1 := &gen.CommandEnvelope{ 410 | CommandType: "test", 411 | AggregateId: "123", 412 | Aggregate: "users", 413 | Payload: []byte(`{"name": "John Doe", "country": "USA"}`), 414 | } 415 | b2, _ := proto.Marshal(cm1) 416 | _, err = js.PublishAsync("cmds.users.test", b2) 417 | if err != nil { 418 | t.Fatalf("Failed to publish command: %v", err) 419 | } 420 | 421 | time.Sleep(100 * time.Millisecond) // Wait for command processing 422 | if c1.v != "got command" { 423 | t.Errorf("Expected command handler to receive command, got: %s", c1.v) 424 | } 425 | 426 | } 427 | 428 | type EventProMgr struct { 429 | } 430 | 431 | func (e *EventProMgr) Handle(event *gen.EventEnvelope) ([]*gen.CommandEnvelope, error) { 432 | switch event.EventType { 433 | case "created": 434 | return []*gen.CommandEnvelope{{ 435 | CommandType: "test", 436 | AggregateId: "123", 437 | Aggregate: "users", 438 | Payload: event.Payload, 439 | }}, nil 440 | } 441 | return nil, nil 442 | } 443 | 444 | type cmd1 struct { 445 | v string 446 | } 447 | 448 | func (c *cmd1) Handle(m *gen.CommandEnvelope) ([]*gen.EventEnvelope, error) { 449 | switch m.CommandType { 450 | case "test": 451 | c.v = "got command" 452 | return []*gen.EventEnvelope{{ 453 | EventType: "test", 454 | AggregateType: m.Aggregate, 455 | AggregateId: m.AggregateId, 456 | Payload: m.Payload, 457 | }}, nil 458 | } 459 | return nil, nil 460 | } 461 | --------------------------------------------------------------------------------