Repository: hallgren/eventsourcing Branch: master Commit: c3821dcdc7ce Files: 81 Total size: 245.2 KB Directory structure: gitextract_nsi1zgi3/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── aggregate/ │ ├── aggregate.go │ ├── aggregate_test.go │ ├── idgenerator.go │ ├── root.go │ ├── root_test.go │ ├── snapshot.go │ └── snapshot_test.go ├── core/ │ ├── event.go │ ├── eventstore.go │ ├── fetcher.go │ ├── go.mod │ ├── snapshotstore.go │ └── testsuite/ │ ├── eventstoresuite.go │ ├── fetcher.go │ └── snapshotstoresuite.go ├── event.go ├── eventsourcing.go ├── eventsourcing_test.go ├── eventstore/ │ ├── bbolt/ │ │ ├── README.md │ │ ├── bbolt.go │ │ ├── bbolt_test.go │ │ ├── go.mod │ │ ├── go.sum │ │ └── iterator.go │ ├── esdb/ │ │ ├── README.md │ │ ├── esdb.go │ │ ├── esdb_test.go │ │ ├── go.mod │ │ ├── go.sum │ │ └── iterator.go │ ├── kurrent/ │ │ ├── go.mod │ │ ├── go.sum │ │ ├── iterator.go │ │ ├── kurrent.go │ │ └── kurrent_test.go │ ├── memory/ │ │ ├── iterator.go │ │ ├── memory.go │ │ └── memory_test.go │ └── sql/ │ ├── README.md │ ├── go.mod │ ├── go.sum │ ├── iterator.go │ ├── migrate.go │ ├── postgres.go │ ├── postgres_test.go │ ├── sqlite.go │ ├── sqlite_test.go │ ├── sqlserver.go │ └── sqlserver_test.go ├── example/ │ ├── README.md │ ├── go.mod │ ├── go.sum │ ├── order/ │ │ ├── cmd/ │ │ │ └── main.go │ │ ├── order.go │ │ └── order_test.go │ └── tictactoe/ │ ├── cmd/ │ │ └── main/ │ │ └── main.go │ ├── tictactoe.go │ └── tictactoe_test.go ├── go.mod ├── go.sum ├── internal/ │ ├── encoderjson.go │ └── register.go ├── iterator.go ├── projections.go ├── projections_test.go └── snapshotstore/ ├── memory/ │ ├── memory.go │ └── memory_test.go └── sql/ ├── README.md ├── go.mod ├── go.sum ├── migrate.go ├── postgres.go ├── postgres_test.go ├── sqlite.go └── sqlite_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: # eventstore - package-ecosystem: "gomod" directory: "/eventstore/kurrent/" schedule: interval: "weekly" - package-ecosystem: "gomod" directory: "/eventstore/esdb/" schedule: interval: "weekly" - package-ecosystem: "gomod" directory: "/eventstore/bbolt/" schedule: interval: "weekly" - package-ecosystem: "gomod" directory: "/eventstore/sql/" schedule: interval: "weekly" # snapshotstore - package-ecosystem: "gomod" directory: "/snapshotstore/sql/" schedule: interval: "weekly" ================================================ FILE: .github/workflows/go.yml ================================================ # This workflow will build a golang project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go name: Go permissions: contents: read on: push: branches: [ "master" ] pull_request: branches: [ "master" ] jobs: main: name: Main module runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.19' - name: Build run: go build -v ./... - name: Test run: go test -v -race ./... bbolt: name: bbolt eventstore runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.21' - name: Build run: cd eventstore/bbolt && go build -v ./... - name: Test run: cd eventstore/bbolt && go test -v -race ./... sql: name: sql eventstore runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23' - name: Build run: cd eventstore/sql && go build -v ./... - name: Test run: cd eventstore/sql && go test -v -race ./... esdb: name: esdb eventstore runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.21' - name: Build run: cd eventstore/esdb && go build -v ./... - name: Test run: cd eventstore/esdb && go test -v -race ./... kurrent: name: kurrent eventstore runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.21' - name: Build run: cd eventstore/kurrent && go build -v ./... - name: Test run: cd eventstore/kurrent && go test -v -race ./... sqlsnapshot: name: sql snapshotstore runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23' - name: Build run: cd snapshotstore/sql && go build -v ./... - name: Test run: cd snapshotstore/sql && go test -v -race ./... ================================================ FILE: .gitignore ================================================ example/example .idea coverage.txt *.swp go.work ================================================ FILE: LICENSE ================================================ __________________________________ Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ================================================ FILE: Makefile ================================================ all: go fmt ./... go build #core cd core && go build # event stores cd eventstore/bbolt && go build cd eventstore/sql && go build cd eventstore/esdb && go build test: #core cd core && go test -count 1 ./... # event stores cd eventstore/bbolt && go test -count 1 ./... cd eventstore/sql && go test -count 1 ./... cd eventstore/esdb && go test esdb_test.go -count 1 ./... # main go test -count 1 ./... update: # event stores cd eventstore/bbolt && go get -t -u ./... && go mod tidy cd eventstore/sql && go get -u ./... && go mod tidy cd eventstore/esdb && go get -t -u ./... && go mod tidy #snaptshot stores cd snapshotstore/sql && go get -u ./... && go mod tidy # main go get -t -u ./... && go mod tidy ================================================ FILE: README.md ================================================ # Overview This set of modules is a post implementation of [@jen20's](https://github.com/jen20) way of implementing event sourcing. You can find the original blog post [here](https://jen20.dev/post/event-sourcing-in-go/) and github repo [here](https://github.com/jen20/go-event-sourcing-sample). It's structured in two main parts: * [Aggregate](https://github.com/hallgren/eventsourcing?tab=readme-ov-file#aggregate) - Model and Load/Save aggregates (write side). * [Consuming events](https://github.com/hallgren/eventsourcing?tab=readme-ov-file#projections) - Handle events and build read-models (read side). ## Event Sourcing [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) is a technique to make it possible to capture all changes to an application state as a sequence of events. ### Aggregate The *aggregate* is the central point where events are bound. The aggregate struct needs to embed `aggregate.Root` to get the aggregate behaviors. *Person* aggregate where the Aggregate Root is embedded next to the `Name` and `Age` properties. ```go type Person struct { aggregate.Root Name string Age int } ``` The aggregate needs to implement the `Transition(event eventsourcing.Event)` and `Register(r aggregate.RegisterFunc)` methods to fulfill the aggregate interface. These methods define how events are transformed to build the aggregate state and which events to register into the repository. Example of the Transition method on the `Person` aggregate. ```go // Transition the person state dependent on the events func (person *Person) Transition(event eventsourcing.Event) { switch e := event.Data().(type) { case *Born: person.Age = 0 person.Name = e.Name case *AgedOneYear: person.Age += 1 } } ``` The `Born` event sets the `Person` property `Age` and `Name`, and the `AgedOneYear` adds one year to the `Age` property. This makes the state of the aggregate flexible and could easily change in the future if required. Example or the Register method: ```go // Register callback method that register Person events to the repository func (person *Person) Register(r aggregate.RegisterFunc) { r(&Born{}, &AgedOneYear{}) } ``` The `Born` and `AgedOneYear` events are now registered to the repository when the aggregate is registered. ### Event An event is a clean struct with exported properties that contains the state of the event. Example of two events from the `Person` aggregate. ```go // Initial event type Born struct { Name string } // Event that happens once a year type AgedOneYear struct {} ``` When an aggregate is first created, an event is needed to initialize the state of the aggregate. No event, no aggregate. Example of a constructor that returns the `Person` aggregate and inside it binds an event via the `aggregate.TrackChange` function. It's possible to define rules that the aggregate must uphold before an event is created, in this case the person's name must not be blank. ```go // CreatePerson constructor for Person func CreatePerson(name string) (*Person, error) { if name == "" { return nil, errors.New("name can't be blank") } person := Person{} aggregate.TrackChange(&person, &Born{Name: name}) return &person, nil } ``` When a person is created, more events could be created via methods on the `Person` aggregate. Below is the `GrowOlder` method which in turn triggers the event `AgedOneYear`. ```go // GrowOlder command func (person *Person) GrowOlder() { aggregate.TrackChange(person, &AgedOneYear{}) } ``` Internally the `aggregate.TrackChange` function calls the `Transition` method on the aggregate to transform the aggregate based on the newly created event. To bind metadata to events use the `aggregate.TrackChangeWithMetadata` function. The `Event` has the following behaviours.. ```go type Event struct { // aggregate identifier AggregateID() string // the aggregate version when this event was created Version() Version // the global version is based on all events (this value is only set after the event is saved to the event store) GlobalVersion() Version // aggregate type (Person in the example above) AggregateType() string // UTC time when the event was created Timestamp() time.Time // the specific event data specified in the application (Born{}, AgedOneYear{}) Data() interface{} // data that don´t belongs to the application state (could be correlation id or other request references) Metadata() map[string]interface{} } ``` ### Aggregate ID The identifier on the aggregate is default set by a random generated string via the crypt/rand pkg. It is possible to change the default behavior in two ways. * Set a specific id on the aggregate via the SetID func. ```go var id = "123" person := Person{} err := person.SetID(id) ``` * Change the id generator via the global aggregate.SetIDFunc function. ```go var counter = 0 f := func() string { counter++ return fmt.Sprint(counter) } aggregate.SetIDFunc(f) ``` ## Save/Load Aggregate To save and load aggregates there are exported functions on the aggregate package. `core.EventStore` is an interface exposing the actual storage system. More on that in later sections. The `aggregate` interface is automatically applied on the application defined aggregate when `aggregate.Root` is embedded. ```go // Save stores the aggregate events in the supplied event store aggregate.Save(es core.EventStore, a aggregate) error // Load returns the aggregate based on its events aggregate.Load(ctx context.Context, es core.EventStore, id string, a aggregate) error ``` To be able to save and load aggregates they have to be registered and each aggregate has to implement the `Register` method. On top of that the aggregate itself has to be registered via the `aggregate.Register` function. ```go // the person aggregate has to be registered in the repository aggregate.Register(&Person{}) ``` ### Event Store The only thing an event store handles are events, and it must implement the following interface. ```go // saves events to the underlaying data store. Save(events []core.Event) error // fetches events based on identifier and type but also after a specific version. The version is used to load events that happened after a snapshot was taken. Get(id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) ``` There are four implementations in this repository. * [SQL](https://github.com/hallgren/eventsourcing/blob/master/eventstore/sql/README.md) - `go get github.com/hallgren/eventsourcing/eventstore/sql` * SQLite * Postgres * Microsoft SQL Server * Bolt - `go get github.com/hallgren/eventsourcing/eventstore/bbolt` * Event Store DB - `go get github.com/hallgren/eventsourcing/eventstore/esdb` * Kurrent DB - `go get github.com/hallgren/eventsourcing/eventstore/kurrent` * RAM Memory - part of the main module External event stores: * [DynamoDB](https://github.com/fd1az/dynamo-es) by [fd1az](https://github.com/fd1az) * [SQL pgx driver](https://github.com/CentralConcept/go-eventsourcing-pgx/tree/main/eventstore/pgx) ### Custom event store If you want to store events in a database beside the already implemented event stores you can implement, or provide, another event store. It has to implement the `core.EventStore` interface to support the eventsourcing.EventRepository. ```go type EventStore interface { Save(events []core.Event) error Get(id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) } ``` The event store needs to import the `github.com/hallgren/eventsourcing/core` module that expose the `core.Event`, `core.Version` and `core.Iterator` types. ### Encoder Before an `eventsourcing.Event` is stored into a event store it has to be transformed into an `core.Event`. This is done with an encoder that serializes the data properties `Data` and `Metadata` into `[]byte`. When an event is fetched the encoder deserialize the `Data` and `Metadata` `[]byte` back into their actual types. The default encoder uses the `encoding/json` package for serialization/deserialization. It can be replaced by using the `eventsourcing.SetEventEncoder(e Encoder)` function on the eventsourcing package, it has to follow this interface: ```go type Encoder interface { Serialize(v interface{}) ([]byte, error) Deserialize(data []byte, v interface{}) error } ``` ### Realtime Event Subscription For now the real time event subscription has been removed as I'm not satisfied with the exported API. Please fill an issue if you want it back. ## Snapshot If an aggregate has a lot of events it can take some time fetching and building the aggregate. This can be optimized with the help of a snapshot. The snapshot is the state of the aggregate on a specific version. Instead of iterating all events, only the events after the version are iterated and used to build the aggregate. The use of snapshots is optional and is exposed via the snapshot functions on the aggregate package. ### Save/Load Snapshot It's only possible to save a snapshot if it has no pending events, meaning that the aggregate events are saved before saving the snapshot. ```go // Saves a snapshot aggregate.SaveSnapshot(ss core.SnapshotStore, s snapshot) error // Loads the aggregate only from the snapshot state not adding events that were saved after the snapshot was taken aggregate.LoadSnapshot(ctx context.Context, ss core.SnapshotStore, id string, s snapshot) error // Loads the aggregate from the snapshot and also adds events aggregate.LoadFromSnapshot(ctx context.Context, es core.EventStore, ss core.SnapshotStore, id string, as aggregateSnapshot) error ``` ### Snapshot Store Like the event store's the snapshot repository is built on the same design. The snapshot store has to implement the following methods. ```go type SnapshotStore interface { Save(snapshot Snapshot) error Get(ctx context.Context, id, aggregateType string) (Snapshot, error) } ``` There are two implementations in this repository. * [SQL](https://github.com/hallgren/eventsourcing/blob/master/snapshotstore/sql/README.md) - `go get github.com/hallgren/eventsourcing/snapshotstore/sql` * SQLite * Postgres * RAM Memory - part of the main module External event stores: * [SQL pgx driver](https://github.com/CentralConcept/go-eventsourcing-pgx/tree/main/snapshotstore/pgx) ### Unexported aggregate properties As unexported properties on a struct are not possible to serialize there is the same limitation on aggregates. To fix this there are optional callback methods that can be added to the aggregate struct. ```go type snapshot interface { SerializeSnapshot(SerializeFunc) ([]byte, error) DeserializeSnapshot(DeserializeFunc, []byte) error } ``` Example: ```go // aggregate type Person struct { aggregate.Root unexported string } // snapshot struct type PersonSnapshot struct { UnExported string } // callback that maps the aggregate to the snapshot struct with the exported property func (s *Person) SerializeSnapshot(m aggregate.SnapshotMarshal) ([]byte, error) { snap := PersonSnapshot{ Unexported: s.unexported, } return m(snap) } // callback to map the snapshot back to the aggregate func (s *Person) DeserializeSnapshot(m aggregate.SnapshotUnmarshal, b []byte) error { snap := PersonSnapshot{} err := m(b, &snap) if err != nil { return err } s.unexported = snap.UnExported return nil } ``` It's possible to change the default json encoder by the `eventsourcing.SetSnapshotEncoder(e Encoder)` function. ## Projections Projections is a way to build read-models based on events. A read-model is a way to expose data from events in a different form. Where the form is optimized for read-only queries. If you want more background on projections check out Derek Comartin projections article [Projections in Event Sourcing: Build ANY model you want!](https://codeopinion.com/projections-in-event-sourcing-build-any-model-you-want/) or Martin Fowler's [CQRS](https://martinfowler.com/bliki/CQRS.html). ### Projection A _projection_ is created from the `eventsourcing.NewProjection` function. The method takes a `core.Fetcher` and a `callbackFunc` and returns a pointer to the projection. ```go p := pr.Projection(f core.Fetcher, c callbackFunc) ``` The core.Fetcher type `func() (core.Iterator, error)`, i.e it return the same signature that event stores return when they return events. ```go type Fetcher func() (core.Iterator, error) ``` The `callbackFunc` is called for every iterated event inside the projection. The event is typed and can be handled in the same way as the aggregate `Transition()` method. ```go type callbackFunc func(e eventsourcing.Event) error ``` Example: Creates a projection that fetches all events from an event store and handle them in the callbackF. ```go p := eventsourcing.NewProjection(es.All(0, 1), func(event eventsourcing.Event) error { switch e := event.Data().(type) { case *Born: // handle the event } return nil }) ``` ### Projection execution A projection can be started in three different ways. #### RunOnce RunOnce fetch events from the event store one time. It returns true if there were events to iterate otherwise false. ```go RunOnce() (bool, ProjectionResult) ``` #### RunToEnd RunToEnd fetches events from the event store until it reaches the end of the event stream. A context is passed in making it possible to cancel the projections from the outside. ```go RunToEnd(ctx context.Context) ProjectionResult ``` `RunOnce` and `RunToEnd` both return a ProjectionResult ```go type ProjectionResult struct { Error error ProjectionName string LastHandledEvent Event } ``` * **Error** Is set if the projection returned an error * **ProjectionName** Is the name of the projection * **LastHandledEvent** The last successfully handled event (can be useful during debugging) #### Run Run will run forever until the event consumer is returning an error or if it's canceled from the outside. When it hits the end of the event stream it will start a timer and sleep the time set in the projection property `Pace`. ```go Run(ctx context.Context, pace time.Duration) error ``` A running projection can be triggered manually via `TriggerAsync()` or `TriggerSync()`. ### Projection properties A projection has a set of properties that can affect its behavior. * **Strict** - Default true and it will trigger an error if a fetched event is not registered in the event `Register`. This forces all events to be handled by the callbackFunc. * **Name** - The name of the projection. Can be useful when debugging multiple running projections. The default name is the index it was created from the projection handler. ### Run multiple projections #### Group A set of projections can run concurrently in a group. ```go g := eventsourcing.NewProjectionGroup(p1, p2, p3) ``` A group is started with `g.Start()` where each projection will run in a separate go routine. Errors from a projection can be retrieved from an error channel `g.ErrChan`. The `g.Stop()` method is used to halt all projections in the group and it returns when all projections have stopped. ```go // create three projections p1 := eventsourcing.NewProjection(es.All(0, 1), callbackF) p2 := eventsourcing.NewProjection(es.All(0, 1), callbackF) p3 := eventsourcing.NewProjection(es.All(0, 1), callbackF) // create a group containing the projections g := eventsourcing.NewProjectionGroup(p1, p2, p3) // Start runs all projections concurrently g.Start() // Stop terminate all projections and wait for them to return defer g.Stop() // handling error in projection or termination from outside select { case err := <-g.ErrChan: // handle the error case <-doneChan: // stop signal from the out side return } ``` The pace of the projection can be changed with the `Pace` property. Default is every 10 seconds. If the pace is not fast enough for some scenario it's possible to trigger manually. `TriggerAsync()`: Triggers all projections in the group and returns. `TriggerSync()`: Triggers all projections in the group and waits for them running to the end of their event streams. #### Race Compared to a group the race is a one shot operation. Instead of fetching events continuously it's used to iterate and process all existing events and then return. The `Race()` method starts the projections and runs them to the end of their event streams. When all projections are finished the method returns. ```go eventsourcing.ProjectionsRace(cancelOnError bool, projections ...*Projection) ([]ProjectionResult, error) ``` If `cancelOnError` is set to true the method will halt all projections and return if any projection is returning an error. The returned `[]ProjectionResult` is a collection of all projection results. Race example: ```go // create two projections p1 := eventsourcing.NewProjection(es.All(0, 1), callbackF) p2 := eventsourcing.NewProjection(es.All(0, 1), callbackF) // true make the race return on error in any projection result, err := eventsourcing.ProjectionsRace(true, r1, r2) ``` ================================================ FILE: aggregate/aggregate.go ================================================ package aggregate import ( "context" "errors" "fmt" "reflect" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/internal" ) type RegisterFunc = func(events ...interface{}) // Aggregate interface to use the aggregate root specific methods type aggregate interface { root() *Root Transition(event eventsourcing.Event) Register(RegisterFunc) } // Load returns the aggregate based on its events func Load(ctx context.Context, es core.EventStore, id string, a aggregate) error { if reflect.ValueOf(a).Kind() != reflect.Ptr { return eventsourcing.ErrAggregateNeedsToBeAPointer } root := a.root() iterator, err := getEvents(ctx, es, id, aggregateType(a), root.Version()) if err != nil { return err } defer iterator.Close() for iterator.Next() { select { case <-ctx.Done(): return ctx.Err() default: event, err := iterator.Value() if err != nil { return err } buildFromHistory(a, []eventsourcing.Event{event}) } } if root.Version() == 0 { return eventsourcing.ErrAggregateNotFound } return nil } // LoadFromSnapshot fetch the aggregate by first get its snapshot and later append events after the snapshot was stored // This can speed up the load time of aggregates with many events func LoadFromSnapshot(ctx context.Context, es core.EventStore, ss core.SnapshotStore, id string, as aggregateSnapshot) error { err := LoadSnapshot(ctx, ss, id, as) if err != nil { return err } return Load(ctx, es, id, as) } // Save stores the aggregate events in the supplied event store func Save(es core.EventStore, a aggregate) error { root := a.root() // return as quick as possible when no events to process if len(root.events) == 0 { return nil } if !internal.GlobalRegister.AggregateRegistered(a) { return fmt.Errorf("%s %w", aggregateType(a), eventsourcing.ErrAggregateNotRegistered) } globalVersion, err := saveEvents(es, root.Events()) if err != nil { return err } // update the global version on the aggregate root.globalVersion = globalVersion // set internal properties and reset the events slice lastEvent := root.events[len(root.events)-1] root.version = lastEvent.Version() root.events = []eventsourcing.Event{} return nil } // Register registers the aggregate and its events func Register(a aggregate) { internal.GlobalRegister.Register(a) } // Save events to the event store func saveEvents(eventStore core.EventStore, events []eventsourcing.Event) (eventsourcing.Version, error) { var esEvents = make([]core.Event, 0, len(events)) for _, event := range events { data, err := internal.EventEncoder.Serialize(event.Data()) if err != nil { return 0, err } metadata, err := internal.EventEncoder.Serialize(event.Metadata()) if err != nil { return 0, err } esEvent := core.Event{ AggregateID: event.AggregateID(), Version: core.Version(event.Version()), AggregateType: event.AggregateType(), Timestamp: event.Timestamp(), Data: data, Metadata: metadata, Reason: event.Reason(), } _, ok := internal.GlobalRegister.EventRegistered(esEvent) if !ok { return 0, fmt.Errorf("%s %w", esEvent.Reason, eventsourcing.ErrEventNotRegistered) } esEvents = append(esEvents, esEvent) } err := eventStore.Save(esEvents) if err != nil { if errors.Is(err, core.ErrConcurrency) { return 0, eventsourcing.ErrConcurrency } return 0, fmt.Errorf("error from event store: %w", err) } return eventsourcing.Version(esEvents[len(esEvents)-1].GlobalVersion), nil } // getEvents return event iterator based on aggregate inputs from the event store func getEvents(ctx context.Context, eventStore core.EventStore, id, aggregateType string, fromVersion eventsourcing.Version) (*eventsourcing.Iterator, error) { // fetch events after the current version of the aggregate that could be fetched from the snapshot store eventIterator, err := eventStore.Get(ctx, id, aggregateType, core.Version(fromVersion)) if err != nil { return nil, err } return &eventsourcing.Iterator{ CoreIterator: eventIterator, }, nil } ================================================ FILE: aggregate/aggregate_test.go ================================================ package aggregate_test import ( "context" "testing" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/aggregate" "github.com/hallgren/eventsourcing/eventstore/memory" ss "github.com/hallgren/eventsourcing/snapshotstore/memory" ) func TestSaveAndLoadAggregate(t *testing.T) { es := memory.Create() aggregate.Register(&Person{}) person, err := CreatePerson("kalle") if err != nil { t.Fatal(err) } err = aggregate.Save(es, person) if err != nil { t.Fatalf("could not save aggregate, err: %v", err) } // make sure the global version is set to 1 if person.GlobalVersion() != 1 { t.Fatalf("global version is: %d expected: 1", person.GlobalVersion()) } twin := Person{} err = aggregate.Load(context.Background(), es, person.ID(), &twin) if err != nil { t.Fatalf("could not get aggregate err: %v", err) } // Check internal aggregate version if person.Version() != twin.Version() { t.Fatalf("Wrong version org %q copy %q", person.Version(), twin.Version()) } // Check person Name if person.Name != twin.Name { t.Fatalf("Wrong Name org %q copy %q", person.Name, twin.Name) } } func TestLoadAggregateFromSnapshot(t *testing.T) { es := memory.Create() ss := ss.Create() aggregate.Register(&Person{}) person, err := CreatePerson("kalle") if err != nil { t.Fatal(err) } err = aggregate.Save(es, person) if err != nil { t.Fatalf("could not save aggregate, err: %v", err) } // store snapshot err = aggregate.SaveSnapshot(ss, person) if err != nil { t.Fatal(err) } // add one more event to the person aggregate person.GrowOlder() err = aggregate.Save(es, person) // load person to person2 from snaphost and events person2 := &Person{} err = aggregate.LoadFromSnapshot(context.Background(), es, ss, person.ID(), person2) if err != nil { t.Fatal(err) } if person.Age != person2.Age { t.Fatalf("expected same age on person(%d) and person2(%d)", person.Age, person2.Age) } } func TestLoadNoneExistingAggregate(t *testing.T) { es := memory.Create() aggregate.Register(&Person{}) p := Person{} err := aggregate.Load(context.Background(), es, "none_existing", &p) if err != eventsourcing.ErrAggregateNotFound { t.Fatal("could not get aggregate") } } ================================================ FILE: aggregate/idgenerator.go ================================================ package aggregate import ( "crypto/rand" ) // idFunc is a global function that generates aggregate id's. // It could be changed from the outside via the SetIDFunc function. var idFunc = randSeq // SetIDFunc is used to change how aggregate ID's are generated // default is a random string func SetIDFunc(f func() string) { idFunc = f } func randSeq() string { id, err := generateRandomString(20) if err != nil { return "" } return id } func generateRandomString(n int) (string, error) { const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" bytes, err := generateRandomBytes(n) if err != nil { return "", err } for i, b := range bytes { bytes[i] = letters[b%byte(len(letters))] } return string(bytes), nil } func generateRandomBytes(n int) ([]byte, error) { b := make([]byte, n) _, err := rand.Read(b) return b, err } ================================================ FILE: aggregate/root.go ================================================ package aggregate import ( "reflect" "time" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/core" ) // Root to be included into aggregates to give it the aggregate root behaviors type Root struct { id string version eventsourcing.Version globalVersion eventsourcing.Version events []eventsourcing.Event } const emptyID = "" // TrackChange is used internally by behaviour methods to apply a state change to // the current instance and also track it in order that it can be persisted later. func TrackChange(a aggregate, data interface{}) { TrackChangeWithMetadata(a, data, nil) } // TrackChangeWithMetadata is used internally by behaviour methods to apply a state change to // the current instance and also track it in order that it can be persisted later. // meta data is handled by this func to store none related application state func TrackChangeWithMetadata(a aggregate, data interface{}, metadata map[string]interface{}) { ar := a.root() // This can be overwritten in the constructor of the aggregate if ar.id == emptyID { ar.id = idFunc() } event := eventsourcing.NewEvent( core.Event{ AggregateID: ar.id, Version: ar.nextVersion(), AggregateType: aggregateType(a), Timestamp: time.Now().UTC(), }, data, metadata, ) ar.events = append(ar.events, event) a.Transition(event) } // buildFromHistory builds the aggregate state from events func buildFromHistory(a aggregate, events []eventsourcing.Event) { root := a.root() for _, event := range events { a.Transition(event) //Set the aggregate ID root.id = event.AggregateID() // Make sure the aggregate is in the correct version (the last event) root.version = event.Version() root.globalVersion = event.GlobalVersion() } } func (ar *Root) nextVersion() core.Version { return core.Version(ar.Version()) + 1 } // SetID opens up the possibility to set manual aggregate ID from the outside func (ar *Root) SetID(id string) error { if ar.id != emptyID { return eventsourcing.ErrAggregateAlreadyExists } ar.id = id return nil } // ID returns the aggregate ID as a string func (ar *Root) ID() string { return ar.id } // root returns the included Aggregate Root state, and is used from the interface Aggregate. // //nolint:unused func (ar *Root) root() *Root { return ar } // Version return the version based on events that are not stored func (ar *Root) Version() eventsourcing.Version { if len(ar.events) > 0 { return ar.events[len(ar.events)-1].Version() } return eventsourcing.Version(ar.version) } // GlobalVersion returns the global version based on the last stored event func (ar *Root) GlobalVersion() eventsourcing.Version { return eventsourcing.Version(ar.globalVersion) } // Events return the aggregate events from the aggregate // make a copy of the slice preventing outsiders modifying events. // //nolint:gosimple // for some reason copy does not work func (ar *Root) Events() []eventsourcing.Event { e := make([]eventsourcing.Event, len(ar.events)) // convert internal event to external event for i, event := range ar.events { e[i] = event } return e } // UnsavedEvents return true if there's unsaved events on the aggregate func (ar *Root) UnsavedEvents() bool { return len(ar.events) > 0 } func aggregateType(a interface{}) string { return reflect.TypeOf(a).Elem().Name() } ================================================ FILE: aggregate/root_test.go ================================================ package aggregate_test import ( "encoding/json" "errors" "fmt" "testing" "time" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/aggregate" ) // Person aggregate type Person struct { aggregate.Root Name string Age int Dead int } // Born event type Born struct { Name string } // AgedOneYear event type AgedOneYear struct { } // CreatePerson constructor for the Person func CreatePerson(name string) (*Person, error) { if name == "" { return nil, errors.New("name can't be blank") } person := Person{} aggregate.TrackChange(&person, &Born{Name: name}) return &person, nil } // CreatePersonWithID constructor for the Person that sets the aggregate ID from the outside func CreatePersonWithID(id, name string) (*Person, error) { if name == "" { return nil, errors.New("name can't be blank") } person := Person{} err := person.SetID(id) if err == eventsourcing.ErrAggregateAlreadyExists { return nil, err } else if err != nil { return nil, err } aggregate.TrackChange(&person, &Born{Name: name}) return &person, nil } // GrowOlder command func (person *Person) GrowOlder() { metaData := make(map[string]interface{}) metaData["foo"] = "bar" aggregate.TrackChangeWithMetadata(person, &AgedOneYear{}, metaData) } // Register bind the events to the repository when the aggregate is registered. func (person *Person) Register(f aggregate.RegisterFunc) { f(&Born{}, &AgedOneYear{}) } // Transition the person state dependent on the events func (person *Person) Transition(event eventsourcing.Event) { switch e := event.Data().(type) { case *Born: person.Age = 0 person.Name = e.Name case *AgedOneYear: person.Age += 1 } } func (person *Person) SerializeSnapshot(aggregate.SnapshotMarshal) ([]byte, error) { return json.Marshal(person) } func (person *Person) DeserializeSnapshot(f aggregate.SnapshotUnmarshal, d []byte) error { return json.Unmarshal(d, person) } func TestPersonWithNoEvents(t *testing.T) { person := Person{} if person.Version() != 0 { t.Fatalf("should have version 0 had %d", person.Version()) } } func TestCreateNewPerson(t *testing.T) { timeBefore := time.Now().UTC() person, err := CreatePerson("kalle") if err != nil { t.Fatal("Error when creating person", err.Error()) } if person.Name != "kalle" { t.Fatal("Wrong person Name") } if person.Age != 0 { t.Fatal("Wrong person Age") } if len(person.Events()) != 1 { t.Fatal("There should be one event on the person aggregateRoot") } if person.Version() != 1 { t.Fatal("Wrong version on the person aggregateRoot", person.Version()) } if person.Events()[0].Timestamp().Before(timeBefore) { t.Fatal("event timestamp before timeBefore") } if person.Events()[0].Timestamp().After(time.Now().UTC()) { t.Fatal("event timestamp after current time") } if person.Events()[0].GlobalVersion() != 0 { t.Fatalf("global version should not be set when event is created, was %d", person.Events()[0].GlobalVersion()) } if !person.UnsavedEvents() { t.Fatal("there should be event on the aggregate") } } func TestCreateNewPersonWithIDFromOutside(t *testing.T) { id := "123" person, err := CreatePersonWithID(id, "kalle") if err != nil { t.Fatal("Error when creating person", err.Error()) } if person.ID() != id { t.Fatal("Wrong aggregate ID on the person aggregateRoot", person.ID()) } } func TestBlankName(t *testing.T) { _, err := CreatePerson("") if err == nil { t.Fatal("The constructor should return error on blank Name") } } func TestSetIDOnExistingPerson(t *testing.T) { person, err := CreatePerson("Kalle") if err != nil { t.Fatal("The constructor returned error") } err = person.SetID("new_id") if err == nil { t.Fatal("Should not be possible to set ID on already existing person") } } func TestPersonAgedOneYear(t *testing.T) { person, _ := CreatePerson("kalle") person.GrowOlder() if len(person.Events()) != 2 { t.Fatal("There should be two event on the person aggregateRoot", person.Events()) } if person.Events()[len(person.Events())-1].Reason() != "AgedOneYear" { t.Fatal("The last event reason should be AgedOneYear", person.Events()[len(person.Events())-1].Reason()) } d, ok := person.Events()[1].Metadata()["foo"] if !ok { t.Fatal("meta data not present") } if d.(string) != "bar" { t.Fatal("wrong meta data") } if person.ID() == "" { t.Fatal("aggregate ID should not be empty") } } func TestPersonGrewTenYears(t *testing.T) { person, _ := CreatePerson("kalle") for i := 1; i <= 10; i++ { person.GrowOlder() } if person.Age != 10 { t.Fatal("person has the wrong Age") } } func TestSetIDFunc(t *testing.T) { var counter = 0 f := func() string { counter++ return fmt.Sprint(counter) } aggregate.SetIDFunc(f) for i := 1; i < 10; i++ { person, _ := CreatePerson("kalle") if person.ID() != fmt.Sprint(i) { t.Fatalf("id not set via the new SetIDFunc, exp: %d got: %s", i, person.ID()) } } } func TestIDFuncGeneratingRandomIDs(t *testing.T) { var ids = map[string]struct{}{} for i := 1; i < 100000; i++ { person, _ := CreatePerson("kalle") _, exists := ids[person.ID()] if exists { t.Fatalf("id: %s, already created", person.ID()) } ids[person.ID()] = struct{}{} } } ================================================ FILE: aggregate/snapshot.go ================================================ package aggregate import ( "context" "errors" "reflect" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/internal" ) type SnapshotMarshal func(v interface{}) ([]byte, error) type SnapshotUnmarshal func(data []byte, v interface{}) error // snapshot interface is used to serialize an aggregate that has properties that are not exported type snapshot interface { root() *Root SerializeSnapshot(f SnapshotMarshal) ([]byte, error) DeserializeSnapshot(f SnapshotUnmarshal, d []byte) error } type aggregateSnapshot interface { aggregate snapshot } // LoadSnapshot build the aggregate based on its snapshot data not including its events. // Beware that it could be more events that has happened after the snapshot was taken func LoadSnapshot(ctx context.Context, ss core.SnapshotStore, id string, s snapshot) error { if reflect.ValueOf(s).Kind() != reflect.Ptr { return eventsourcing.ErrAggregateNeedsToBeAPointer } err := getSnapshot(ctx, ss, id, s) if err != nil && errors.Is(err, core.ErrSnapshotNotFound) { return eventsourcing.ErrAggregateNotFound } return err } func getSnapshot(ctx context.Context, ss core.SnapshotStore, id string, s snapshot) error { snap, err := ss.Get(ctx, id, aggregateType(s)) if err != nil { return err } err = s.DeserializeSnapshot(internal.SnapshotEncoder.Deserialize, snap.State) if err != nil { return err } // set the internal aggregate properties root := s.root() root.globalVersion = eventsourcing.Version(snap.GlobalVersion) root.version = eventsourcing.Version(snap.Version) root.id = snap.ID return nil } // SaveSnapshot will only store the snapshot and will return an error if there are events that are not stored func SaveSnapshot(ss core.SnapshotStore, s snapshot) error { root := s.root() if len(root.Events()) > 0 { return eventsourcing.ErrUnsavedEvents } state := []byte{} var err error state, err = s.SerializeSnapshot(internal.SnapshotEncoder.Serialize) if err != nil { return err } snapshot := core.Snapshot{ ID: root.ID(), Type: aggregateType(s), Version: core.Version(root.Version()), GlobalVersion: core.Version(root.GlobalVersion()), State: state, } return ss.Save(snapshot) } ================================================ FILE: aggregate/snapshot_test.go ================================================ package aggregate_test import ( "context" "errors" "testing" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/aggregate" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/eventstore/memory" snap "github.com/hallgren/eventsourcing/snapshotstore/memory" ) func createPerson() *Person { es := memory.Create() aggregate.Register(&Person{}) person, err := CreatePerson("kalle") if err != nil { panic(err) } aggregate.Save(es, person) return person } func TestSaveAndGetSnapshot(t *testing.T) { snapshotStore := snap.Create() person := createPerson() err := aggregate.SaveSnapshot(snapshotStore, person) if err != nil { t.Fatalf("could not save aggregate, err: %v", err) } twin := Person{} err = aggregate.LoadSnapshot(context.Background(), snapshotStore, person.ID(), &twin) if err != nil { t.Fatalf("could not get aggregate, err: %v", err) } // Check internal aggregate version if person.Version() != twin.Version() { t.Fatalf("Wrong version org %q copy %q", person.Version(), twin.Version()) } if person.ID() != twin.ID() { t.Fatalf("Wrong id org %q copy %q", person.ID(), twin.ID()) } if person.Name != twin.Name { t.Fatalf("Wrong name org: %q copy %q", person.Name, twin.Name) } } func TestGetNoneExistingSnapshotOrEvents(t *testing.T) { snapshotStore := snap.Create() person := Person{} err := aggregate.LoadSnapshot(context.Background(), snapshotStore, "none_existing_id", &person) if !errors.Is(err, eventsourcing.ErrAggregateNotFound) { t.Fatal("should get error when no snapshot or event stored for aggregate") } } func TestGetNoneExistingSnapshot(t *testing.T) { snapshotStore := snap.Create() person := Person{} err := aggregate.LoadSnapshot(context.Background(), snapshotStore, "none_existing_id", &person) if !errors.Is(err, eventsourcing.ErrAggregateNotFound) { t.Fatal("should get error when no snapshot stored for aggregate") } } func TestSaveSnapshotWithUnsavedEvents(t *testing.T) { snapshotStore := snap.Create() person, err := CreatePerson("kalle") if err != nil { t.Fatal(err) } err = aggregate.SaveSnapshot(snapshotStore, person) if err == nil { t.Fatalf("should not be able to save snapshot with unsaved events") } } // test custom snapshot struct to handle non-exported properties on aggregate type snapshot struct { aggregate.Root unexported string Exported string // to be able to save the snapshot after events are added to it. repo core.EventStore } type Event struct{} type Event2 struct{} func New() *snapshot { es := memory.Create() aggregate.Register(&snapshot{}) s := snapshot{} aggregate.TrackChange(&s, &Event{}) s.repo = es aggregate.Save(es, &s) return &s } func (s *snapshot) Command() { aggregate.TrackChange(s, &Event2{}) aggregate.Save(s.repo, s) } func (s *snapshot) Transition(e eventsourcing.Event) { switch e.Data().(type) { case *Event: s.unexported = "unexported" s.Exported = "Exported" case *Event2: s.unexported = "unexported2" s.Exported = "Exported2" } } // Register bind the events to the repository when the aggregate is registered. func (s *snapshot) Register(f aggregate.RegisterFunc) { f(&Event{}, &Event2{}) } type snapshotInternal struct { UnExported string Exported string } func (s *snapshot) SerializeSnapshot(f aggregate.SnapshotMarshal) ([]byte, error) { snap := snapshotInternal{ UnExported: s.unexported, Exported: s.Exported, } return f(snap) } func (s *snapshot) DeserializeSnapshot(f aggregate.SnapshotUnmarshal, b []byte) error { snap := snapshotInternal{} err := f(b, &snap) if err != nil { return err } s.unexported = snap.UnExported s.Exported = snap.Exported return nil } func TestSnapshotNoneExported(t *testing.T) { snapshotStore := snap.Create() snap := New() err := aggregate.SaveSnapshot(snapshotStore, snap) if err != nil { t.Fatal(err) } snap.Command() err = aggregate.SaveSnapshot(snapshotStore, snap) if err != nil { t.Fatal(err) } snap2 := snapshot{} err = aggregate.LoadSnapshot(context.Background(), snapshotStore, snap.ID(), &snap2) if err != nil { t.Fatal(err) } if snap.unexported != snap2.unexported { t.Fatalf("none exported value differed %s %s", snap.unexported, snap2.unexported) } if snap.Exported != snap2.Exported { t.Fatalf("exported value differed %s %s", snap.Exported, snap2.Exported) } } ================================================ FILE: core/event.go ================================================ package core import ( "time" ) // Version is the event version used in event.Version and event.GlobalVersio type Version uint64 // Event holding meta data and the application specific event in the Data property type Event struct { AggregateID string Version Version GlobalVersion Version AggregateType string Timestamp time.Time Reason string // based on the Data type Data []byte // interface{} on the external Event type Metadata []byte // map[string]interface{} on the external Event type } ================================================ FILE: core/eventstore.go ================================================ package core import ( "context" "errors" ) // ErrConcurrency when the currently saved version of the aggregate differs from the new ones var ErrConcurrency = errors.New("concurrency error") // Iterator is the interface an event store Get needs to return type Iterator interface { Next() bool Value() (Event, error) Close() } // EventStore interface expose the methods an event store must uphold type EventStore interface { Save(events []Event) error Get(ctx context.Context, id string, aggregateType string, afterVersion Version) (Iterator, error) } ================================================ FILE: core/fetcher.go ================================================ package core // Fetcher is the event fetch function concumed by projections type Fetcher func() (Iterator, error) ================================================ FILE: core/go.mod ================================================ module github.com/hallgren/eventsourcing/core go 1.13 ================================================ FILE: core/snapshotstore.go ================================================ package core import ( "context" "errors" ) // ErrSnapshotNotFound returned when no snapshot is found in the snapshot store var ErrSnapshotNotFound = errors.New("snapshot not found") // Snapshot holds current state of an aggregate type Snapshot struct { ID string Type string Version Version GlobalVersion Version State []byte } // SnapshotStore expose the methods a snapshot store must uphold type SnapshotStore interface { Save(snapshot Snapshot) error Get(ctx context.Context, id, aggregateType string) (Snapshot, error) } ================================================ FILE: core/testsuite/eventstoresuite.go ================================================ package testsuite import ( "context" "encoding/json" "errors" "fmt" "math/rand" "sync" "testing" "time" "github.com/hallgren/eventsourcing/core" ) var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) func AggregateID() string { r := seededRand.Intn(999999999999) return fmt.Sprintf("%d", r) } type eventstoreFunc = func() (core.EventStore, func(), error) // Status represents the Red, Silver or Gold tier level of a FrequentFlierAccount type Status int const ( StatusRed Status = iota StatusSilver Status = iota StatusGold Status = iota ) type FrequentFlierAccountCreated struct { AccountId string OpeningMiles int OpeningTierPoints int } type StatusMatched struct { NewStatus Status } type FlightTaken struct { MilesAdded int TierPointsAdded int } var aggregateType = "FrequentFlierAccount" var timestamp = time.Now() func eventToByte(i interface{}) []byte { b, _ := json.Marshal(i) return b } func testEvents(aggregateID string) []core.Event { metadata := make(map[string]interface{}) metadata["test"] = "hello" history := []core.Event{ {AggregateID: aggregateID, Version: 1, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FrequentFlierAccountCreated", Data: eventToByte(&FrequentFlierAccountCreated{AccountId: "1234567", OpeningMiles: 10000, OpeningTierPoints: 0}), Metadata: eventToByte(metadata)}, {AggregateID: aggregateID, Version: 2, AggregateType: aggregateType, Timestamp: timestamp, Reason: "StatusMatched", Data: eventToByte(&StatusMatched{NewStatus: StatusSilver}), Metadata: eventToByte(metadata)}, {AggregateID: aggregateID, Version: 3, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FlightTaken", Data: eventToByte(&FlightTaken{MilesAdded: 2525, TierPointsAdded: 5}), Metadata: eventToByte(metadata)}, {AggregateID: aggregateID, Version: 4, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FlightTaken", Data: eventToByte(&FlightTaken{MilesAdded: 2512, TierPointsAdded: 5}), Metadata: eventToByte(metadata)}, {AggregateID: aggregateID, Version: 5, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FlightTaken", Data: eventToByte(&FlightTaken{MilesAdded: 5600, TierPointsAdded: 5}), Metadata: eventToByte(metadata)}, {AggregateID: aggregateID, Version: 6, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FlightTaken", Data: eventToByte(&FlightTaken{MilesAdded: 3000, TierPointsAdded: 3}), Metadata: eventToByte(metadata)}, } return history } func testEventsPartTwo(aggregateID string) []core.Event { history := []core.Event{ {AggregateID: aggregateID, Version: 7, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FlightTaken", Data: eventToByte(&FlightTaken{MilesAdded: 5600, TierPointsAdded: 5})}, {AggregateID: aggregateID, Version: 8, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FlightTaken", Data: eventToByte(&FlightTaken{MilesAdded: 3000, TierPointsAdded: 3})}, } return history } func testEventOtherAggregate(aggregateID string) core.Event { return core.Event{AggregateID: aggregateID, Version: 1, AggregateType: aggregateType, Timestamp: timestamp, Reason: "FrequentFlierAccountCreated", Data: eventToByte(&FrequentFlierAccountCreated{AccountId: "1234567", OpeningMiles: 10000, OpeningTierPoints: 0})} } func Test(t *testing.T, esFunc eventstoreFunc) { tests := []struct { title string run func(es core.EventStore) error }{ {"should save and get events", saveAndGetEvents}, {"should get events after version", getEventsAfterVersion}, {"should not save events in wrong version", saveEventsInWrongVersion}, {"should save and get event concurrently", saveAndGetEventsConcurrently}, {"should return error when no events", getErrWhenNoEvents}, {"should get global event order from save", saveReturnGlobalEventOrder}, } for _, test := range tests { t.Run(test.title, func(t *testing.T) { es, closeFunc, err := esFunc() if err != nil { t.Fatal(err) } err = test.run(es) if err != nil { // make use of t.Error instead of t.Fatal to make sure the closeFunc is executed t.Error(err) } closeFunc() }) } } func saveAndGetEvents(es core.EventStore) error { aggregateID := AggregateID() events := testEvents(aggregateID) fetchedEvents := []core.Event{} err := es.Save(events) if err != nil { return err } iterator, err := es.Get(context.Background(), aggregateID, aggregateType, 0) if err != nil { return err } for iterator.Next() { event, err := iterator.Value() if err != nil { return err } fetchedEvents = append(fetchedEvents, event) } iterator.Close() if len(fetchedEvents) != len(testEvents(aggregateID)) { return errors.New("wrong number of events returned") } if fetchedEvents[0].Version != testEvents(aggregateID)[0].Version { return errors.New("wrong events returned") } // Add more events to the same aggregate event stream err = es.Save(testEventsPartTwo(aggregateID)) if err != nil { return err } fetchedEventsIncludingPartTwo := []core.Event{} iterator, err = es.Get(context.Background(), aggregateID, aggregateType, 0) if err != nil { return err } for iterator.Next() { event, err := iterator.Value() if err != nil { break } fetchedEventsIncludingPartTwo = append(fetchedEventsIncludingPartTwo, event) } iterator.Close() if len(fetchedEventsIncludingPartTwo) != len(append(testEvents(aggregateID), testEventsPartTwo(aggregateID)...)) { return errors.New("wrong number of events returned") } if fetchedEventsIncludingPartTwo[0].Version != testEvents(aggregateID)[0].Version { return errors.New("wrong event version returned") } if fetchedEventsIncludingPartTwo[0].AggregateID != testEvents(aggregateID)[0].AggregateID { return errors.New("wrong event aggregateID returned") } if fetchedEventsIncludingPartTwo[0].AggregateType != testEvents(aggregateID)[0].AggregateType { return errors.New("wrong event aggregateType returned") } if fetchedEventsIncludingPartTwo[0].Reason != testEvents(aggregateID)[0].Reason { return errors.New("wrong event reason returned") } return nil } func getEventsAfterVersion(es core.EventStore) error { var fetchedEvents []core.Event aggregateID := AggregateID() err := es.Save(testEvents(aggregateID)) if err != nil { return err } iterator, err := es.Get(context.Background(), aggregateID, aggregateType, 1) if err != nil { return err } for iterator.Next() { event, err := iterator.Value() if err != nil { break } fetchedEvents = append(fetchedEvents, event) } iterator.Close() // Should return one less event if len(fetchedEvents) != len(testEvents(aggregateID))-1 { return fmt.Errorf("wrong number of events returned exp: %d, got:%d", len(fetchedEvents), len(testEvents(aggregateID))-1) } // first event version should be 2 if fetchedEvents[0].Version != 2 { return fmt.Errorf("wrong events returned") } return nil } func saveEventsInWrongVersion(es core.EventStore) error { aggregateID := AggregateID() events := testEventsPartTwo(aggregateID) err := es.Save(events) if !errors.Is(err, core.ErrConcurrency) { return errors.New("should not be able to save events that are out of sync compared to the storage order") } return nil } func saveAndGetEventsConcurrently(es core.EventStore) error { wg := sync.WaitGroup{} var err error aggregateID := AggregateID() wg.Add(10) for i := 0; i < 10; i++ { events := testEvents(fmt.Sprintf("%s-%d", aggregateID, i)) go func() { e := es.Save(events) if e != nil { err = e } wg.Done() }() } if err != nil { return err } wg.Wait() wg.Add(10) for i := 0; i < 10; i++ { eventID := fmt.Sprintf("%s-%d", aggregateID, i) go func() { defer wg.Done() iterator, e := es.Get(context.Background(), eventID, aggregateType, 0) if e != nil { err = e return } events := make([]core.Event, 0) for iterator.Next() { event, err := iterator.Value() if err != nil { break } events = append(events, event) } iterator.Close() if len(events) != 6 { err = fmt.Errorf("wrong number of events fetched, expecting 6 got %d", len(events)) return } }() } if err != nil { return err } wg.Wait() return nil } func getErrWhenNoEvents(es core.EventStore) error { aggregateID := AggregateID() iterator, err := es.Get(context.Background(), aggregateID, aggregateType, 0) if err != nil { return err } defer iterator.Close() if iterator.Next() { return fmt.Errorf("expect no event when no events are saved") } return nil } func saveReturnGlobalEventOrder(es core.EventStore) error { aggregateID := AggregateID() aggregateID2 := AggregateID() events := testEvents(aggregateID) err := es.Save(events) if err != nil { return err } if events[len(events)-1].GlobalVersion == 0 { return fmt.Errorf("expected global event order > 0 on last event got %d", events[len(events)-1].GlobalVersion) } events2 := []core.Event{testEventOtherAggregate(aggregateID2)} err = es.Save(events2) if err != nil { return err } if events2[0].GlobalVersion <= events[len(events)-1].GlobalVersion { return fmt.Errorf("expected larger global event order got %d", events2[0].GlobalVersion) } return nil } /* re-activate when esdb eventstore have global event order on each stream func setGlobalVersionOnSavedEvents(es eventsourcing.EventStore) error { events := testEvents() err := es.Save(events) if err != nil { return err } eventsGet, err := es.Get(events[0].AggregateID, events[0].AggregateType, 0) if err != nil { return err } var g eventsourcing.Version for _, e := range eventsGet { g++ if e.GlobalVersion != g { return fmt.Errorf("expected global version to be in sequens exp: %d, was: %d", g, e.GlobalVersion) } } return nil } */ ================================================ FILE: core/testsuite/fetcher.go ================================================ package testsuite import ( "fmt" "testing" "github.com/hallgren/eventsourcing/core" ) func TestFetcher(t *testing.T, es core.EventStore, fetcher core.Fetcher) { globalVersion := core.Version(0) aggregateID := AggregateID() events := testEvents(aggregateID) err := es.Save(events) if err != nil { t.Fatal(err) } iter, err := fetcher() if err != nil { t.Fatal(err) } err = verify(iter, globalVersion, events) if err != nil { iter.Close() t.Fatal(err) } iter.Close() // set the globalVersion to the length of the events as not all event stores update the // globalVersion on the events after they are saved globalVersion = core.Version(len(events)) events2 := testEventsPartTwo(aggregateID) err = es.Save(events2) if err != nil { t.Fatal(err) } // run fetcher second time - should not restart from first event iter2, err := fetcher() if err != nil { t.Fatal(err) } defer iter2.Close() err = verify(iter2, globalVersion, events2) if err != nil { t.Fatal(err) } } // verifies that the events from the iterator has a globalversion higher than sent in globalVersion and that // the events are the same from the iterator and sent in event. func verify(iter core.Iterator, globalVersion core.Version, expEvents []core.Event) error { i := 0 for iter.Next() { event, err := iter.Value() if err != nil { return err } if event.Version != expEvents[i].Version && event.AggregateID != expEvents[i].AggregateID { return fmt.Errorf("missmatch in expected event version %q and actual version %q, expected aggregate id %q actual aggregate id %q", expEvents[i].Version, event.Version, expEvents[i].AggregateID, event.AggregateID) } if event.GlobalVersion <= globalVersion { return fmt.Errorf("event global version (%q) is lower than previos event %q", event.GlobalVersion, globalVersion) } globalVersion = event.GlobalVersion i++ } if i != len(expEvents) { return fmt.Errorf("expected %d events got %d", len(expEvents), i) } return nil } ================================================ FILE: core/testsuite/snapshotstoresuite.go ================================================ package testsuite import ( "context" "errors" "fmt" "testing" "github.com/hallgren/eventsourcing/core" ) type snapshotstoreFunc = func() (core.SnapshotStore, func(), error) func TestSnapshotStore(t *testing.T, ssFunc snapshotstoreFunc) { tests := []struct { title string run func(es core.SnapshotStore) error }{ {"should save and get snapshot", saveAndGetSnapshot}, {"should get error when getting none existing snapshot", getNoneExistingSnapshot}, } for _, test := range tests { t.Run(test.title, func(t *testing.T) { ss, closeFunc, err := ssFunc() if err != nil { t.Fatal(err) } err = test.run(ss) if err != nil { // make use of t.Error instead of t.Fatal to make sure the closeFunc is executed t.Error(err) } closeFunc() }) } } func saveAndGetSnapshot(ss core.SnapshotStore) error { snapshot := core.Snapshot{ ID: "id", Type: "person", Version: 1, GlobalVersion: 1, State: []byte("123"), } err := ss.Save(snapshot) if err != nil { return err } s, err := ss.Get(context.Background(), "id", "person") if err != nil { return err } if string(s.State) != "123" { return errors.New("wrong snapshot state") } if s.Version != snapshot.Version { return fmt.Errorf("exp version %d got %d", snapshot.Version, s.Version) } if s.GlobalVersion != snapshot.GlobalVersion { return fmt.Errorf("exp global version %d got %d", snapshot.GlobalVersion, s.GlobalVersion) } s, err = ss.Get(context.Background(), "none_existing_id", "person") if !errors.Is(err, core.ErrSnapshotNotFound) { return err } return nil } func getNoneExistingSnapshot(ss core.SnapshotStore) error { _, err := ss.Get(context.Background(), "id", "person") if !errors.Is(err, core.ErrSnapshotNotFound) { return err } return nil } ================================================ FILE: event.go ================================================ package eventsourcing import ( "reflect" "time" "github.com/hallgren/eventsourcing/core" ) // Version is the event version used in event.Version and event.GlobalVersion type Version core.Version type Event struct { event core.Event // internal event data interface{} metadata map[string]interface{} } func NewEvent(e core.Event, data interface{}, metadata map[string]interface{}) Event { return Event{event: e, data: data, metadata: metadata} } func (e Event) Data() interface{} { return e.data } func (e Event) Metadata() map[string]interface{} { return e.metadata } func (e Event) AggregateType() string { return e.event.AggregateType } func (e Event) AggregateID() string { return e.event.AggregateID } func (e Event) Reason() string { if e.data == nil { return "" } return reflect.TypeOf(e.data).Elem().Name() } func (e Event) Version() Version { return Version(e.event.Version) } func (e Event) Timestamp() time.Time { return e.event.Timestamp } func (e Event) GlobalVersion() Version { return Version(e.event.GlobalVersion) } ================================================ FILE: eventsourcing.go ================================================ package eventsourcing import ( "errors" "github.com/hallgren/eventsourcing/internal" ) var ( // ErrAggregateNotFound returns if events not found for aggregate or aggregate was not based on snapshot from the outside ErrAggregateNotFound = errors.New("aggregate not found") // ErrAggregateNotRegistered when saving aggregate when it's not registered in the repository ErrAggregateNotRegistered = errors.New("aggregate not registered") // ErrEventNotRegistered when saving aggregate and one event is not registered in the repository ErrEventNotRegistered = errors.New("event not registered") // ErrConcurrency when the currently saved version of the aggregate differs from the new events ErrConcurrency = errors.New("concurrency error") // ErrAggregateAlreadyExists returned if the aggregateID is set more than one time ErrAggregateAlreadyExists = errors.New("its not possible to set ID on already existing aggregate") // ErrAggregateNeedsToBeAPointer return if aggregate is sent in as value object ErrAggregateNeedsToBeAPointer = errors.New("aggregate needs to be a pointer") // ErrUnsavedEvents aggregate events must be saved before creating snapshot ErrUnsavedEvents = errors.New("aggregate holds unsaved events") ) // Encoder is the interface used to Serialize/Deserialize events and snapshots type Encoder interface { Serialize(v interface{}) ([]byte, error) Deserialize(data []byte, v interface{}) error } // SetEventEncoder change the default JSON encoder that serialize/deserialize events func SetEventEncoder(e Encoder) { internal.EventEncoder = e } // SetSnapshotEncoder change the default JSON encoder that seialize/deserialize snapshots func SetSnapshotEncoder(e Encoder) { internal.SnapshotEncoder = e } ================================================ FILE: eventsourcing_test.go ================================================ package eventsourcing_test /* func TestGetWithContextCancel(t *testing.T) { es := memory.Create() aggregate.AggregateRegister(&Person{}) person, err := CreatePerson("kalle") if err != nil { t.Fatal(err) } err = aggregate.AggregateSave(es, person) if err != nil { t.Fatal("could not save aggregate") } ctx, cancel := context.WithCancel(context.Background()) // cancel the context cancel() err = eventsourcing.AggregateLoad(ctx, es, person.ID(), person) if !errors.Is(err, context.Canceled) { t.Fatalf("expected error context.Canceled but was %v", err) } } func TestSaveWhenAggregateNotRegistered(t *testing.T) { es := memory.Create() eventsourcing.ResetRegsiter() person, err := CreatePerson("kalle") if err != nil { t.Fatal(err) } err = eventsourcing.AggregateSave(es, person) if !errors.Is(err, eventsourcing.ErrAggregateNotRegistered) { t.Fatalf("could save aggregate that was not registered, err: %v", err) } } func TestMultipleSave(t *testing.T) { es := memory.Create() eventsourcing.AggregateRegister(&Person{}) person, err := CreatePerson("kalle") if err != nil { t.Fatal(err) } err = eventsourcing.AggregateSave(es, person) if err != nil { t.Fatalf("could not save aggregate, err: %v", err) } version := person.Version() err = eventsourcing.AggregateSave(es, person) if err != nil { t.Fatalf("save should be a nop, err: %v", err) } if version != person.Version() { t.Fatalf("the nop save should not change the aggregate version exp:%d, actual:%d", version, person.Version()) } } func TestConcurrentRead(t *testing.T) { es := memory.Create() eventsourcing.AggregateRegister(&Person{}) person, err := CreatePerson("kalle") if err != nil { t.Fatal(err) } err = eventsourcing.AggregateSave(es, person) if err != nil { t.Fatal("could not save aggregate") } person2, err := CreatePerson("anka") if err != nil { t.Fatal(err) } err = eventsourcing.AggregateSave(es, person2) if err != nil { t.Fatal("could not save aggregate") } for i := 1; i <= 1000; i++ { p1 := Person{} p2 := Person{} wg := sync.WaitGroup{} wg.Add(2) go func() { eventsourcing.AggregateLoad(context.Background(), es, person.ID(), &p1) wg.Done() }() go func() { eventsourcing.AggregateLoad(context.Background(), es, person2.ID(), &p2) wg.Done() }() wg.Wait() if p1.Name == p2.Name { t.Fatal("name should differ") } } } */ ================================================ FILE: eventstore/bbolt/README.md ================================================ ## BBolt Event Store This event store supports the go.etcd.io/bbolt bolt driver. ### Constructor ```go // New opens the event stream found in the given file. If the file is not found it will be created and // initialized. Will return error if it has problems persisting the changes to the filesystem. func New(dbFile string) (*BBolt, error) { ``` ### Example of use ```go import "github.com/hallgren/eventsourcing/eventstore/bbolt" bboltEventStore, err := bbolt.New("bolt.db") if err != nil { return nil, nil, err } ``` ================================================ FILE: eventstore/bbolt/bbolt.go ================================================ package bbolt import ( "context" "encoding/binary" "encoding/json" "errors" "fmt" "time" "github.com/hallgren/eventsourcing/core" "go.etcd.io/bbolt" ) const ( globalEventOrderBucketName = "global_event_order" ) // BBolt is the eventstore handler type BBolt struct { db *bbolt.DB // The bbolt db where we store everything } type boltEvent struct { AggregateID string Version uint64 GlobalVersion uint64 Reason string AggregateType string Timestamp time.Time Data []byte Metadata []byte // map[string]interface{} } // New opens the event stream found in the given file. If the file is not found it will be created and // initialized. Will return error if it has problems persisting the changes to the filesystem. func New(dbFile string) (*BBolt, error) { db, err := bbolt.Open(dbFile, 0600, &bbolt.Options{ Timeout: 1 * time.Second, }) if err != nil { return nil, err } // Ensure that we have a bucket to store the global event ordering err = db.Update(func(tx *bbolt.Tx) error { if _, err := tx.CreateBucketIfNotExists([]byte(globalEventOrderBucketName)); err != nil { return errors.New("could not create global event order bucket") } return nil }) if err != nil { return nil, err } return &BBolt{ db: db, }, nil } // Save an aggregate (its events) func (e *BBolt) Save(events []core.Event) error { // Return if there is no events to save if len(events) == 0 { return nil } // get bucket name from first event aggregateType := events[0].AggregateType aggregateID := events[0].AggregateID bucketRef := bucketRef(aggregateType, aggregateID) tx, err := e.db.Begin(true) if err != nil { return err } defer tx.Rollback() evBucket := tx.Bucket(bucketRef) if evBucket == nil { // Ensure that we have a bucket named events_aggregateType_aggregateID for the given aggregate err = e.createBucket(bucketRef, tx) if err != nil { return errors.New("could not create aggregate events bucket") } evBucket = tx.Bucket(bucketRef) } currentVersion := uint64(0) cursor := evBucket.Cursor() k, obj := cursor.Last() if k != nil { lastEvent := boltEvent{} err := json.Unmarshal(obj, &lastEvent) if err != nil { return errors.New(fmt.Sprintf("could not serialize event, %v", err)) } currentVersion = lastEvent.Version } // Make sure no other has saved event to the same aggregate concurrently if core.Version(currentVersion)+1 != events[0].Version { return core.ErrConcurrency } globalBucket := tx.Bucket([]byte(globalEventOrderBucketName)) if globalBucket == nil { return errors.New("global bucket not found") } var globalSequence uint64 for i, event := range events { sequence, err := evBucket.NextSequence() if err != nil { return errors.New(fmt.Sprintf("could not get sequence for %#v", string(bucketRef))) } // We need to establish a global event order that spans over all buckets. This is so that we can be // able to play the event (or send) them in the order that they was entered into this database. // The global sequence bucket contains an ordered line of pointer to all events on the form bucket_name:seq_num globalSequence, err = globalBucket.NextSequence() if err != nil { return errors.New("could not get next sequence for global bucket") } // build the internal bolt event bEvent := boltEvent{ AggregateID: event.AggregateID, AggregateType: event.AggregateType, Version: uint64(event.Version), GlobalVersion: globalSequence, Reason: event.Reason, Timestamp: event.Timestamp, Metadata: event.Metadata, Data: event.Data, } value, err := json.Marshal(bEvent) if err != nil { return errors.New(fmt.Sprintf("could not serialize event, %v", err)) } err = evBucket.Put(itob(sequence), value) if err != nil { return errors.New(fmt.Sprintf("could not save event %#v in bucket", event)) } err = globalBucket.Put(itob(globalSequence), value) if err != nil { return errors.New(fmt.Sprintf("could not save global sequence pointer for %#v", string(bucketRef))) } // override the event in the slice exposing the GlobalVersion to the caller events[i].GlobalVersion = core.Version(globalSequence) } return tx.Commit() } // Get aggregate events func (e *BBolt) Get(ctx context.Context, id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) { tx, err := e.db.Begin(false) if err != nil { return nil, err } bucket := tx.Bucket(bucketRef(aggregateType, id)) if bucket == nil { return &Iterator{tx: tx}, nil } cursor := bucket.Cursor() return &Iterator{tx: tx, cursor: cursor, startPosition: position(afterVersion)}, nil } // All iterate over event in GlobalEvents order func (e *BBolt) All(start core.Version) core.Fetcher { iter := Iterator{} return func() (core.Iterator, error) { tx, err := e.db.Begin(false) if err != nil { return nil, err } bucket := tx.Bucket([]byte(globalEventOrderBucketName)) if bucket == nil { return &Iterator{tx: tx}, nil } // set start from second call and forward if iter.CurrentGlobalVersion != 0 { // dont add 1 to CurrentGlobalVersion as the index is zero based start = iter.CurrentGlobalVersion } cursor := bucket.Cursor() iter.tx = tx iter.cursor = cursor iter.startPosition = position(core.Version(start)) return &iter, nil //return &iter{tx: tx, cursor: cursor, startPosition: position(core.Version(start))}, nil } } // Close closes the event stream and the underlying database func (e *BBolt) Close() error { return e.db.Close() } // CreateBucket creates a bucket func (e *BBolt) createBucket(bucketRef []byte, tx *bbolt.Tx) error { // Ensure that we have a bucket named event_type for the given type if _, err := tx.CreateBucketIfNotExists(bucketRef); err != nil { return errors.New(fmt.Sprintf("could not create bucket for %s: %s", string(bucketRef), err)) } return nil } // bucketRef return the reference where to store and fetch events func bucketRef(aggregateType, aggregateID string) []byte { return []byte(aggregateType + "_" + aggregateID) } // calculate the correct posiotion and convert to bbolt key type func position(p core.Version) []byte { return itob(uint64(p + 1)) } // itob returns an 8-byte big endian representation of v. func itob(v uint64) []byte { b := make([]byte, 8) binary.BigEndian.PutUint64(b, v) return b } ================================================ FILE: eventstore/bbolt/bbolt_test.go ================================================ package bbolt_test import ( "os" "testing" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/eventstore/bbolt" ) func TestEventStoreSuite(t *testing.T) { f := func() (core.EventStore, func(), error) { dbFile := "bolt.db" es, err := bbolt.New(dbFile) if err != nil { return nil, nil, err } return es, func() { es.Close() os.Remove(dbFile) }, nil } testsuite.Test(t, f) } func TestFetchFuncAll(t *testing.T) { dbFile := "bolt.db" es, err := bbolt.New(dbFile) if err != nil { t.Fatal(err) } defer func() { es.Close() os.Remove(dbFile) }() testsuite.TestFetcher(t, es, es.All(0)) } ================================================ FILE: eventstore/bbolt/go.mod ================================================ module github.com/hallgren/eventsourcing/eventstore/bbolt go 1.23 toolchain go1.23.6 require ( github.com/hallgren/eventsourcing/core v0.5.2 go.etcd.io/bbolt v1.4.3 ) require golang.org/x/sys v0.29.0 // indirect // replace github.com/hallgren/eventsourcing/core => ../../core ================================================ FILE: eventstore/bbolt/go.sum ================================================ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/hallgren/eventsourcing/core v0.5.2 h1:knvM1jP0zziiybce+Au7ysYvZQnDwxkV+/RFZWNDMiw= github.com/hallgren/eventsourcing/core v0.5.2/go.mod h1:rgo2kFwNVCb0bzUub5nOPlUYNlFkp1uUQBEQx5fM3Lk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: eventstore/bbolt/iterator.go ================================================ package bbolt import ( "encoding/json" "errors" "fmt" "github.com/hallgren/eventsourcing/core" "go.etcd.io/bbolt" ) type Iterator struct { tx *bbolt.Tx cursor *bbolt.Cursor startPosition []byte value []byte CurrentGlobalVersion core.Version } // Close closes the iterator func (i *Iterator) Close() { i.tx.Rollback() } func (i *Iterator) Next() bool { if i.cursor == nil { return false } // first time Next is called go to the start position if i.value == nil { _, i.value = i.cursor.Seek(i.startPosition) } else { _, i.value = i.cursor.Next() } if i.value == nil { return false } return true } // Next return the next event func (i *Iterator) Value() (core.Event, error) { bEvent := boltEvent{} err := json.Unmarshal(i.value, &bEvent) if err != nil { return core.Event{}, errors.New(fmt.Sprintf("could not deserialize event, %v", err)) } event := core.Event{ AggregateID: bEvent.AggregateID, AggregateType: bEvent.AggregateType, Version: core.Version(bEvent.Version), GlobalVersion: core.Version(bEvent.GlobalVersion), Timestamp: bEvent.Timestamp, Metadata: bEvent.Metadata, Data: bEvent.Data, Reason: bEvent.Reason, } i.CurrentGlobalVersion = core.Version(bEvent.GlobalVersion) return event, nil } ================================================ FILE: eventstore/esdb/README.md ================================================ # esdb event store The esdb event store is supporting the [EventStoreDB](https://www.eventstore.com) database. It's based on the module github.com/EventStore/EventStore-Client-Go/v3 for reading and writing events. ================================================ FILE: eventstore/esdb/esdb.go ================================================ package esdb import ( "context" "github.com/EventStore/EventStore-Client-Go/v4/esdb" "github.com/hallgren/eventsourcing/core" ) const streamSeparator = "-" // ESDB is the event store handler type ESDB struct { client *esdb.Client contentType esdb.ContentType } // Open binds the event store db client func Open(client *esdb.Client, jsonSerializer bool) *ESDB { // defaults to binary var contentType esdb.ContentType if jsonSerializer { contentType = esdb.ContentTypeJson } return &ESDB{ client: client, contentType: contentType, } } // Save persists events to the database func (es *ESDB) Save(events []core.Event) error { // If no event return no error if len(events) == 0 { return nil } var streamOptions esdb.AppendToStreamOptions aggregateID := events[0].AggregateID aggregateType := events[0].AggregateType version := events[0].Version stream := stream(aggregateType, aggregateID) esdbEvents := make([]esdb.EventData, len(events)) for i, event := range events { eventData := esdb.EventData{ ContentType: es.contentType, EventType: event.Reason, Data: event.Data, Metadata: event.Metadata, } esdbEvents[i] = eventData } if version > 1 { // StreamRevision value -2 due to version in the eventsourcing pkg start on 1 but in esdb on 0 // and also the AppendToStream streamOptions expected revision is one version before the first appended event. streamOptions.ExpectedRevision = esdb.StreamRevision{Value: uint64(version) - 2} } else if version == 1 { streamOptions.ExpectedRevision = esdb.NoStream{} } wr, err := es.client.AppendToStream(context.Background(), stream, streamOptions, esdbEvents...) if err != nil { if err, ok := esdb.FromError(err); !ok { if err.Code() == esdb.ErrorCodeWrongExpectedVersion { // return typed error if version is not the expected. return core.ErrConcurrency } } return err } for i := range events { // Set all events GlobalVersion to the last events commit position. events[i].GlobalVersion = core.Version(wr.CommitPosition) } return nil } func (es *ESDB) Get(ctx context.Context, id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) { streamID := stream(aggregateType, id) from := esdb.StreamRevision{Value: uint64(afterVersion)} stream, err := es.client.ReadStream(ctx, streamID, esdb.ReadStreamOptions{From: from}, ^uint64(0)) if err != nil { if err, ok := esdb.FromError(err); !ok { if err.Code() == esdb.ErrorCodeResourceNotFound { return &Iterator{}, nil } } return nil, err } return &Iterator{stream: stream}, nil } func stream(aggregateType, aggregateID string) string { return aggregateType + streamSeparator + aggregateID } ================================================ FILE: eventstore/esdb/esdb_test.go ================================================ package esdb_test import ( "context" "testing" "github.com/EventStore/EventStore-Client-Go/v4/esdb" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" es "github.com/hallgren/eventsourcing/eventstore/esdb" ) func TestSuite(t *testing.T) { ctx := context.Background() req := testcontainers.ContainerRequest{ Image: "eventstore/eventstore:latest", ExposedPorts: []string{"2113/tcp"}, WaitingFor: wait.ForListeningPort("2113/tcp"), Cmd: []string{"--insecure", "--run-projections=All", "--mem-db"}, } container, err := testcontainers.GenericContainer( ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }, ) if err != nil { t.Fatal(err) } defer container.Terminate(ctx) endpoint, err := container.PortEndpoint(ctx, "2113", "esdb") if err != nil { t.Fatal(err) } f := func() (core.EventStore, func(), error) { settings, err := esdb.ParseConnectionString(endpoint + "?tls=false") if err != nil { return nil, nil, err } db, err := esdb.NewClient(settings) if err != nil { return nil, nil, err } es := es.Open(db, true) return es, func() { }, nil } testsuite.Test(t, f) } ================================================ FILE: eventstore/esdb/go.mod ================================================ module github.com/hallgren/eventsourcing/eventstore/esdb go 1.25.0 require ( github.com/EventStore/EventStore-Client-Go/v4 v4.2.0 github.com/hallgren/eventsourcing/core v0.5.2 github.com/testcontainers/testcontainers-go v0.42.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) // replace github.com/hallgren/eventsourcing/core => ../../core ================================================ FILE: eventstore/esdb/go.sum ================================================ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/EventStore/EventStore-Client-Go/v4 v4.2.0 h1:RXKiJ6pGQsWrhZ1BfKwPc1vKh8EOwNSQOXh11whk0Pw= github.com/EventStore/EventStore-Client-Go/v4 v4.2.0/go.mod h1:KSyk2r/zy2hbkbjHVqBHc0jskYmkNYmXcU5rhMOlWKg= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e h1:XmA6L9IPRdUr28a+SK/oMchGgQy159wvzXA5tJ7l+40= github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e/go.mod h1:AFIo+02s+12CEg8Gzz9kzhCbmbq6JcKNrhHffCGA9z4= github.com/hallgren/eventsourcing/core v0.5.2 h1:knvM1jP0zziiybce+Au7ysYvZQnDwxkV+/RFZWNDMiw= github.com/hallgren/eventsourcing/core v0.5.2/go.mod h1:rgo2kFwNVCb0bzUub5nOPlUYNlFkp1uUQBEQx5fM3Lk= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= ================================================ FILE: eventstore/esdb/iterator.go ================================================ package esdb import ( "strings" "github.com/EventStore/EventStore-Client-Go/v4/esdb" "github.com/hallgren/eventsourcing/core" ) type Iterator struct { stream *esdb.ReadStream event *esdb.ResolvedEvent } // Close closes the stream func (i *Iterator) Close() { i.stream.Close() } // Next steps to the next event in the stream func (i *Iterator) Next() bool { if i.stream == nil { return false } eventESDB, err := i.stream.Recv() if err != nil { return false } i.event = eventESDB return true } // Value returns the event from the stream func (i *Iterator) Value() (core.Event, error) { stream := strings.Split(i.event.Event.StreamID, streamSeparator) event := core.Event{ AggregateID: stream[1], Version: core.Version(i.event.Event.EventNumber) + 1, // +1 as the eventsourcing Version starts on 1 but the esdb event version starts on 0 AggregateType: stream[0], Timestamp: i.event.Event.CreatedDate, Data: i.event.Event.Data, Metadata: i.event.Event.UserMetadata, Reason: i.event.Event.EventType, // Can't get the global version when using the ReadStream method //GlobalVersion: core.Version(event.Event.Position.Commit), } return event, nil } ================================================ FILE: eventstore/kurrent/go.mod ================================================ module github.com/hallgren/eventsourcing/eventstore/kurrent go 1.25.0 require ( github.com/hallgren/eventsourcing/core v0.5.2 github.com/kurrent-io/KurrentDB-Client-Go v1.1.2 github.com/testcontainers/testcontainers-go v0.42.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) // replace github.com/hallgren/eventsourcing/core => ../../core ================================================ FILE: eventstore/kurrent/go.sum ================================================ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hallgren/eventsourcing/core v0.5.2 h1:knvM1jP0zziiybce+Au7ysYvZQnDwxkV+/RFZWNDMiw= github.com/hallgren/eventsourcing/core v0.5.2/go.mod h1:rgo2kFwNVCb0bzUub5nOPlUYNlFkp1uUQBEQx5fM3Lk= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kurrent-io/KurrentDB-Client-Go v1.1.2 h1:Pua/rb8eMbmb4lS3uhf42b6StAVJouA9GD6U611EiM8= github.com/kurrent-io/KurrentDB-Client-Go v1.1.2/go.mod h1:ddlaMCBJtaKn6gjzjilE4eZ+upG6Aqz/R4tamYTHBTE= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= ================================================ FILE: eventstore/kurrent/iterator.go ================================================ package kurrent import ( "strings" "github.com/hallgren/eventsourcing/core" "github.com/kurrent-io/KurrentDB-Client-Go/kurrentdb" ) type Iterator struct { Stream *kurrentdb.ReadStream event *kurrentdb.ResolvedEvent } // Close closes the stream func (i *Iterator) Close() { i.Stream.Close() } // Next steps to the next event in the stream func (i *Iterator) Next() bool { if i.Stream == nil { return false } event, err := i.Stream.Recv() if err != nil { return false } i.event = event return true } // Value returns the event from the stream func (i *Iterator) Value() (core.Event, error) { stream := strings.Split(i.event.Event.StreamID, streamSeparator) event := core.Event{ AggregateID: stream[1], Version: core.Version(i.event.Event.EventNumber) + 1, // +1 as the eventsourcing Version starts on 1 but the kurrent event version starts on 0 AggregateType: stream[0], Timestamp: i.event.Event.CreatedDate, Data: i.event.Event.Data, Metadata: i.event.Event.UserMetadata, Reason: i.event.Event.EventType, // Can't get the global version when using the ReadStream method //GlobalVersion: core.Version(event.Event.Position.Commit), } return event, nil } ================================================ FILE: eventstore/kurrent/kurrent.go ================================================ package kurrent import ( "context" "github.com/hallgren/eventsourcing/core" "github.com/kurrent-io/KurrentDB-Client-Go/kurrentdb" ) const streamSeparator = "-" // Kurrent is the event store handler type Kurrent struct { client *kurrentdb.Client contentType kurrentdb.ContentType } // Open binds the event store db client func Open(client *kurrentdb.Client, jsonSerializer bool) *Kurrent { // defaults to binary var contentType kurrentdb.ContentType if jsonSerializer { contentType = kurrentdb.ContentTypeJson } return &Kurrent{ client: client, contentType: contentType, } } // Save persists events to the database func (es *Kurrent) Save(events []core.Event) error { // If no event return no error if len(events) == 0 { return nil } var streamOptions kurrentdb.AppendToStreamOptions aggregateID := events[0].AggregateID aggregateType := events[0].AggregateType version := events[0].Version stream := stream(aggregateType, aggregateID) KurrentEvents := make([]kurrentdb.EventData, len(events)) for i, event := range events { eventData := kurrentdb.EventData{ ContentType: es.contentType, EventType: event.Reason, Data: event.Data, Metadata: event.Metadata, } KurrentEvents[i] = eventData } if version > 1 { // StreamRevision value -2 due to version in the eventsourcing pkg start on 1 but in kurrent on 0 // and also the AppendToStream streamOptions expected revision is one version before the first appended event. streamOptions.StreamState = kurrentdb.StreamRevision{Value: uint64(version) - 2} } else if version == 1 { streamOptions.StreamState = kurrentdb.NoStream{} } wr, err := es.client.AppendToStream(context.Background(), stream, streamOptions, KurrentEvents...) if err != nil { if err, ok := kurrentdb.FromError(err); !ok { if err.Code() == kurrentdb.ErrorCodeWrongExpectedVersion { // return typed error if version is not the expected. return core.ErrConcurrency } } return err } for i := range events { // Set all events GlobalVersion to the last events commit position. events[i].GlobalVersion = core.Version(wr.CommitPosition) } return nil } func (es *Kurrent) Get(ctx context.Context, id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) { streamID := stream(aggregateType, id) from := kurrentdb.StreamRevision{Value: uint64(afterVersion)} stream, err := es.client.ReadStream(ctx, streamID, kurrentdb.ReadStreamOptions{From: from}, ^uint64(0)) if err != nil { if err, ok := kurrentdb.FromError(err); !ok { if err.Code() == kurrentdb.ErrorCodeResourceNotFound { return &Iterator{}, nil } } return nil, err } return &Iterator{Stream: stream}, nil } func stream(aggregateType, aggregateID string) string { return aggregateType + streamSeparator + aggregateID } ================================================ FILE: eventstore/kurrent/kurrent_test.go ================================================ package kurrent_test import ( "context" "testing" "github.com/kurrent-io/KurrentDB-Client-Go/kurrentdb" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/eventstore/kurrent" ) func TestSuite(t *testing.T) { ctx := context.Background() req := testcontainers.ContainerRequest{ Image: "kurrentplatform/kurrentdb:latest", ExposedPorts: []string{"2113/tcp"}, WaitingFor: wait.ForListeningPort("2113/tcp"), Cmd: []string{"--insecure", "--run-projections=All", "--mem-db"}, } container, err := testcontainers.GenericContainer( ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }, ) if err != nil { t.Fatal(err) } defer container.Terminate(ctx) endpoint, err := container.PortEndpoint(ctx, "2113", "kurrent") if err != nil { t.Fatal(err) } f := func() (core.EventStore, func(), error) { settings, err := kurrentdb.ParseConnectionString(endpoint + "?tls=false") if err != nil { return nil, nil, err } db, err := kurrentdb.NewClient(settings) if err != nil { return nil, nil, err } es := kurrent.Open(db, true) return es, func() { }, nil } testsuite.Test(t, f) } ================================================ FILE: eventstore/memory/iterator.go ================================================ package memory import "github.com/hallgren/eventsourcing/core" type iterator struct { events []core.Event position int event core.Event } func (i *iterator) Next() bool { if len(i.events) <= i.position { return false } i.event = i.events[i.position] i.position++ return true } func (i *iterator) Value() (core.Event, error) { return i.event, nil } func (i *iterator) Close() { i.events = nil i.position = 0 } ================================================ FILE: eventstore/memory/memory.go ================================================ package memory import ( "context" "sync" "github.com/hallgren/eventsourcing/core" ) // Memory is a handler for event streaming type Memory struct { aggregateEvents map[string][]core.Event // The memory structure where we store aggregate events eventsInOrder []core.Event // The global event order lock sync.Mutex } // Create in memory event store func Create() *Memory { return &Memory{ aggregateEvents: make(map[string][]core.Event), eventsInOrder: make([]core.Event, 0), } } // Save an aggregate (its events) func (e *Memory) Save(events []core.Event) error { // Return if there is no events to save if len(events) == 0 { return nil } // make sure its thread safe e.lock.Lock() defer e.lock.Unlock() // get bucket name from first event aggregateType := events[0].AggregateType aggregateID := events[0].AggregateID bucketName := aggregateKey(aggregateType, aggregateID) evBucket := e.aggregateEvents[bucketName] currentVersion := core.Version(0) if len(evBucket) > 0 { // Last version in the list lastEvent := evBucket[len(evBucket)-1] currentVersion = lastEvent.Version } // Make sure no other has saved event to the same aggregate concurrently if core.Version(currentVersion)+1 != events[0].Version { return core.ErrConcurrency } for i, event := range events { // set the global version on the event +1 as if the event was already on the eventsInOrder slice event.GlobalVersion = core.Version(len(e.eventsInOrder) + 1) evBucket = append(evBucket, event) e.eventsInOrder = append(e.eventsInOrder, event) // override the event in the slice exposing the GlobalVersion to the caller events[i].GlobalVersion = event.GlobalVersion } e.aggregateEvents[bucketName] = evBucket return nil } // Get aggregate events func (e *Memory) Get(ctx context.Context, id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) { var events []core.Event // make sure its thread safe e.lock.Lock() defer e.lock.Unlock() for _, e := range e.aggregateEvents[aggregateKey(aggregateType, id)] { if e.Version > afterVersion { events = append(events, e) } } return &iterator{events: events}, ctx.Err() } // Close does nothing func (e *Memory) Close() {} // aggregateKey generates a key to store events against from aggregateType and aggregateID func aggregateKey(aggregateType, aggregateID string) string { return aggregateType + "_" + aggregateID } // globalEvents returns count events in order globally from the start position func (e *Memory) globalEvents(start core.Version, count uint64) ([]core.Event, error) { events := make([]core.Event, 0, count) // make sure its thread safe e.lock.Lock() defer e.lock.Unlock() for _, e := range e.eventsInOrder { // find start position and append until counter is 0 if e.GlobalVersion >= start { events = append(events, e) count-- if count == 0 { break } } } return events, nil } // All iterate over all events in GlobalEvents order func (m *Memory) All(start core.Version, count uint64) core.Fetcher { return func() (core.Iterator, error) { events, err := m.globalEvents(start, count) if err != nil { return nil, err } // no events to fetch if len(events) == 0 { return &iterator{events: []core.Event{}}, nil } // next time the function is called it will start from the last fetched event +1 start = events[len(events)-1].GlobalVersion + 1 return &iterator{events: events}, nil } } ================================================ FILE: eventstore/memory/memory_test.go ================================================ package memory_test import ( "testing" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/eventstore/memory" ) func TestEventStore(t *testing.T) { f := func() (core.EventStore, func(), error) { es := memory.Create() return es, func() { es.Close() }, nil } testsuite.Test(t, f) } func TestFetcherAll(t *testing.T) { es := memory.Create() defer es.Close() testsuite.TestFetcher(t, es, es.All(0, 10)) } ================================================ FILE: eventstore/sql/README.md ================================================ # SQL Event Store The sql eventstore is a module containing multiple sql based event stores that are all based on the database/sql interface in go standard library. The different event stores has specific database schemas that support the different databases. ## SQLite Supports the SQLite database https://www.sqlite.org/ ### Database Schema ```go CREATE TABLE IF NOT EXISTS events ( seq INTEGER PRIMARY KEY AUTOINCREMENT, id VARCHAR NOT NULL, version INTEGER, reason VARCHAR, type VARCHAR, timestamp VARCHAR, data BLOB, metadata BLOB, UNIQUE (id, type, version) ); CREATE INDEX IF NOT EXISTS id_type ON events (id, type); ``` ### Constructor ```go // NewSQLite connection to database NewSQLite(db *sql.DB) (*SQLite, error) // NewSQLiteSingelWriter prevents multiple writers to save events concurrently // // Multiple go routines writing concurrently to sqlite could produce sqlite to lock. // https://www.sqlite.org/cgi/src/doc/begin-concurrent/doc/begin_concurrent.md // // "If there is significant contention for the writer lock, this mechanism can // be inefficient. In this case it is better for the application to use a mutex // or some other mechanism that supports blocking to ensure that at most one // writer is attempting to COMMIT a BEGIN CONCURRENT transaction at a time. // This is usually easier if all writers are part of the same operating system process." NewSQLiteSingelWriter(db *sql.DB) (*SQLite, error) ``` ### Example of use ```go import ( // have to alias the sql package as it use the same name gosql "database/sql" "github.com/hallgren/eventsourcing/eventstore/sql" // use the sqlite driver from mattn in this example _ "github.com/mattn/go-sqlite3" ) db, err := gosql.Open("sqlite3", "file::memory:?cache=shared") if err != nil { return nil, nil, errors.New(fmt.Sprintf("could not open database %v", err)) } err = db.Ping() if err != nil { return nil, nil, errors.New(fmt.Sprintf("could not ping database %v", err)) } sqliteEventStore, err := sql.NewSQLiteSingelWriter(db) if err != nil { return nil, nil, err } ``` ## Postgres Supports the Postgres database https://www.postgresql.org ### Database Schema ```go CREATE TABLE IF NOT EXISTS events ( seq SERIAL PRIMARY KEY, id VARCHAR NOT NULL, version INTEGER, reason VARCHAR, type VARCHAR, timestamp VARCHAR, data BYTEA, metadata BYTEA, UNIQUE (id, type, version) ); CREATE INDEX IF NOT EXISTS id_type ON events (id, type); ``` ### Constructor ```go // NewPostgres connection to database func NewPostgres(db *sql.DB) (*Postgres, error) { ``` ### Example of use ```go import ( // have to alias the sql package as it use the same name gosql "database/sql" // in this example we use the pg postgres driver _ "github.com/lib/pq" "github.com/hallgren/eventsourcing/eventstore/sql" ) db, err := gosql.Open("postgres", dsn) if err != nil { return nil, nil, fmt.Errorf("db open failed: %w", err) } // Test the connection err = db.Ping() if err != nil { return nil, nil, err } postgresEventStore, err := sql.NewPostgres(db) ``` ## Microsoft SQL Server Supports Microsoft SQL Server database https://www.microsoft.com/en-us/sql-server ### Database Schema ```go IF OBJECT_ID('[events]', 'U') IS NULL BEGIN CREATE TABLE [events] ( [seq] INT IDENTITY(1,1) PRIMARY KEY, [id] NVARCHAR(255) NOT NULL, [version] INT, [reason] NVARCHAR(255), [type] NVARCHAR(255), [timestamp] NVARCHAR(255), [data] VARBINARY(MAX), [metadata] VARBINARY(MAX), CONSTRAINT uq_events UNIQUE ([id], [type], [version]) ); END IF NOT EXISTS ( SELECT 1 FROM sys.indexes WHERE name = 'id_type' AND object_id = OBJECT_ID('events') ) BEGIN CREATE INDEX id_type ON [events] ([id], [type]); END IF NOT EXISTS ( SELECT 1 FROM sys.indexes WHERE name = 'id_type' AND object_id = OBJECT_ID('events') ) BEGIN CREATE INDEX id_type ON [events] ([id], [type]); END ``` ### Constructor ```go // NewSQLServer connection to database func NewSQLServer(db *sql.DB) (*SQLServer, error) { ``` ### Example of use ```go import ( // alias the go sql package gosql "database/sql" // uses the sql server driver from denisenkom _ "github.com/denisenkom/go-mssqldb" "github.com/hallgren/eventsourcing/eventstore/sql" ) db, err := gosql.Open("sqlserver", dsn) if err != nil { return err } err = db.Ping() { if err != nil { return err } SqlServerEventStore, err := sql.NewSQLServer(db) ``` ================================================ FILE: eventstore/sql/go.mod ================================================ module github.com/hallgren/eventsourcing/eventstore/sql go 1.25.0 require ( github.com/denisenkom/go-mssqldb v0.12.3 github.com/hallgren/eventsourcing/core v0.5.2 github.com/lib/pq v1.12.3 github.com/mattn/go-sqlite3 v1.14.42 github.com/testcontainers/testcontainers-go v0.42.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/sys v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) // replace github.com/hallgren/eventsourcing/core => ../../core ================================================ FILE: eventstore/sql/go.sum ================================================ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hallgren/eventsourcing/core v0.5.2 h1:knvM1jP0zziiybce+Au7ysYvZQnDwxkV+/RFZWNDMiw= github.com/hallgren/eventsourcing/core v0.5.2/go.mod h1:rgo2kFwNVCb0bzUub5nOPlUYNlFkp1uUQBEQx5fM3Lk= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= ================================================ FILE: eventstore/sql/iterator.go ================================================ package sql import ( "database/sql" "time" "github.com/hallgren/eventsourcing/core" ) type Iterator struct { Rows *sql.Rows CurrentGlobalVersion core.Version } // Next return true if there are more data func (i *Iterator) Next() bool { return i.Rows.Next() } // Value return the an event func (i *Iterator) Value() (core.Event, error) { var globalVersion core.Version var version core.Version var id, reason, typ, timestamp string var data, metadata []byte if err := i.Rows.Scan(&globalVersion, &id, &version, &reason, &typ, ×tamp, &data, &metadata); err != nil { return core.Event{}, err } t, err := time.Parse(time.RFC3339, timestamp) if err != nil { return core.Event{}, err } event := core.Event{ AggregateID: id, Version: version, GlobalVersion: globalVersion, AggregateType: typ, Timestamp: t, Data: data, Metadata: metadata, Reason: reason, } i.CurrentGlobalVersion = globalVersion return event, nil } // Close closes the iterator func (i *Iterator) Close() { i.Rows.Close() } ================================================ FILE: eventstore/sql/migrate.go ================================================ package sql import ( "context" "database/sql" ) func migrate(db *sql.DB, stm []string) error { tx, err := db.BeginTx(context.Background(), nil) if err != nil { return err } defer tx.Rollback() for _, b := range stm { _, err := tx.Exec(b) if err != nil { return err } } return tx.Commit() } ================================================ FILE: eventstore/sql/postgres.go ================================================ package sql import ( "context" "database/sql" "errors" "fmt" "sync" "time" "github.com/hallgren/eventsourcing/core" ) var postgresStm = []string{`CREATE TABLE IF NOT EXISTS events ( seq SERIAL PRIMARY KEY, id VARCHAR NOT NULL, version INTEGER, reason VARCHAR, type VARCHAR, timestamp VARCHAR, data BYTEA, metadata BYTEA, UNIQUE (id, type, version) );`, `CREATE INDEX IF NOT EXISTS id_type ON events (id, type);`, } // Postgres event store handler type Postgres struct { db *sql.DB lock *sync.Mutex } // NewPostgres connection to database func NewPostgres(db *sql.DB) (*Postgres, error) { // make sure the schema is migrated if err := migrate(db, postgresStm); err != nil { return nil, err } s := &Postgres{ db: db, } return s, nil } // Close the connection func (s *Postgres) Close() { s.db.Close() } // Save persists events to the database func (s *Postgres) Save(events []core.Event) error { // If no event return no error if len(events) == 0 { return nil } if s.lock != nil { // prevent multiple writers s.lock.Lock() defer s.lock.Unlock() } aggregateID := events[0].AggregateID aggregateType := events[0].AggregateType tx, err := s.db.BeginTx(context.Background(), nil) if err != nil { return errors.New(fmt.Sprintf("could not start a write transaction, %v", err)) } defer tx.Rollback() var currentVersion core.Version var version int selectStm := `SELECT version FROM events WHERE id=$1 and type=$2 ORDER BY version DESC LIMIT 1` err = tx.QueryRow(selectStm, aggregateID, aggregateType).Scan(&version) if err != nil && err != sql.ErrNoRows { return err } else if err == sql.ErrNoRows { // if no events are saved before set the current version to zero currentVersion = core.Version(0) } else { // set the current version to the last event stored currentVersion = core.Version(version) } // Make sure no other has saved event to the same aggregate concurrently if core.Version(currentVersion)+1 != events[0].Version { return core.ErrConcurrency } var lastInsertedID int64 insert := `INSERT INTO events (id, version, reason, type, timestamp, data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING seq` for i, event := range events { err := tx.QueryRow(insert, event.AggregateID, event.Version, event.Reason, event.AggregateType, event.Timestamp.Format(time.RFC3339), event.Data, event.Metadata).Scan(&lastInsertedID) if err != nil { return err } // override the event in the slice exposing the GlobalVersion to the caller events[i].GlobalVersion = core.Version(lastInsertedID) } return tx.Commit() } // Get the events from database func (s *Postgres) Get(ctx context.Context, id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) { selectStm := `SELECT seq, id, version, reason, type, timestamp, data, metadata FROM events WHERE id=$1 AND type=$2 AND version>$3 ORDER BY version ASC` rows, err := s.db.QueryContext(ctx, selectStm, id, aggregateType, afterVersion) if err != nil { return nil, err } return &Iterator{Rows: rows}, nil } // All iterate over all event in GlobalEvents order func (s *Postgres) All(start core.Version) core.Fetcher { iter := Iterator{} return func() (core.Iterator, error) { // set start from second call and forward if iter.CurrentGlobalVersion != 0 { start = iter.CurrentGlobalVersion + 1 } selectStm := `SELECT seq, id, version, reason, type, timestamp, data, metadata FROM events WHERE seq >= $1 ORDER BY seq ASC` rows, err := s.db.Query(selectStm, start) if err != nil { return nil, err } iter.Rows = rows return &iter, nil } } ================================================ FILE: eventstore/sql/postgres_test.go ================================================ package sql_test import ( "context" gosql "database/sql" "fmt" "testing" "time" _ "github.com/lib/pq" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/eventstore/sql" ) func TestSuitePostgres(t *testing.T) { dsn, closer, err := postgresServer() if err != nil { t.Fatal(err) } defer closer() f := func() (core.EventStore, func(), error) { es, err := postgreConnect(dsn) if err != nil { return nil, nil, err } return es, es.Close, nil } testsuite.Test(t, f) } func TestFetcherAllPostgres(t *testing.T) { dsn, closer, err := postgresServer() if err != nil { t.Fatal(err) } defer closer() es, err := postgreConnect(dsn) if err != nil { t.Fatal(err) } defer es.Close() testsuite.TestFetcher(t, es, es.All(0)) } func postgresServer() (string, func(), error) { ctx := context.Background() // Set up the PostgreSQL container request req := testcontainers.ContainerRequest{ Image: "postgres:16", // Use a specific version ExposedPorts: []string{"5432/tcp"}, Env: map[string]string{ "POSTGRES_USER": "test", "POSTGRES_PASSWORD": "secret", "POSTGRES_DB": "testdb", }, WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(30 * time.Second), } // Start the container postgresContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return "", nil, err } // Get container host and port host, _ := postgresContainer.Host(ctx) port, _ := postgresContainer.MappedPort(ctx, "5432") // Build the DSN dsn := fmt.Sprintf("host=%s port=%s user=test password=secret dbname=testdb sslmode=disable", host, port.Port()) return dsn, func() { postgresContainer.Terminate(ctx) }, nil } func postgreConnect(dsn string) (*sql.Postgres, error) { // Connect using database/sql db, err := gosql.Open("postgres", dsn) if err != nil { return nil, fmt.Errorf("db open failed: %w", err) } // Test the connection err = db.Ping() if err != nil { return nil, err } return sql.NewPostgres(db) } ================================================ FILE: eventstore/sql/sqlite.go ================================================ package sql import ( "context" "database/sql" "errors" "fmt" "sync" "time" "github.com/hallgren/eventsourcing/core" ) var stm = []string{ `CREATE TABLE IF NOT EXISTS events ( seq INTEGER PRIMARY KEY AUTOINCREMENT, id VARCHAR NOT NULL, version INTEGER, reason VARCHAR, type VARCHAR, timestamp VARCHAR, data BLOB, metadata BLOB, UNIQUE (id, type, version) );`, `CREATE INDEX IF NOT EXISTS id_type ON events (id, type);`, } // SQLite event store handler type SQLite struct { db *sql.DB lock *sync.Mutex } // NewSQLite connection to database func NewSQLite(db *sql.DB) (*SQLite, error) { if err := migrate(db, stm); err != nil { return nil, err } return &SQLite{ db: db, }, nil } // NewSQLiteSingelWriter prevents multiple writers to save events concurrently // // Multiple go routines writing concurrently to sqlite could produce sqlite to lock. // https://www.sqlite.org/cgi/src/doc/begin-concurrent/doc/begin_concurrent.md // // "If there is significant contention for the writer lock, this mechanism can // be inefficient. In this case it is better for the application to use a mutex // or some other mechanism that supports blocking to ensure that at most one // writer is attempting to COMMIT a BEGIN CONCURRENT transaction at a time. // This is usually easier if all writers are part of the same operating system process." func NewSQLiteSingelWriter(db *sql.DB) (*SQLite, error) { if err := migrate(db, stm); err != nil { return nil, err } return &SQLite{ db: db, lock: &sync.Mutex{}, }, nil } // Close the connection func (s *SQLite) Close() { s.db.Close() } // Save persists events to the database func (s *SQLite) Save(events []core.Event) error { // If no event return no error if len(events) == 0 { return nil } if s.lock != nil { // prevent multiple writers s.lock.Lock() defer s.lock.Unlock() } aggregateID := events[0].AggregateID aggregateType := events[0].AggregateType tx, err := s.db.BeginTx(context.Background(), nil) if err != nil { return errors.New(fmt.Sprintf("could not start a write transaction, %v", err)) } defer tx.Rollback() var currentVersion core.Version var version int selectStm := `Select version from events where id=? and type=? order by version desc limit 1` err = tx.QueryRow(selectStm, aggregateID, aggregateType).Scan(&version) if err != nil && err != sql.ErrNoRows { return err } else if err == sql.ErrNoRows { // if no events are saved before set the current version to zero currentVersion = core.Version(0) } else { // set the current version to the last event stored currentVersion = core.Version(version) } // Make sure no other has saved event to the same aggregate concurrently if core.Version(currentVersion)+1 != events[0].Version { return core.ErrConcurrency } var lastInsertedID int64 insert := `Insert into events (id, version, reason, type, timestamp, data, metadata) values ($1, $2, $3, $4, $5, $6, $7)` for i, event := range events { res, err := tx.Exec(insert, event.AggregateID, event.Version, event.Reason, event.AggregateType, event.Timestamp.Format(time.RFC3339), event.Data, event.Metadata) if err != nil { return err } lastInsertedID, err = res.LastInsertId() if err != nil { return err } // override the event in the slice exposing the GlobalVersion to the caller events[i].GlobalVersion = core.Version(lastInsertedID) } return tx.Commit() } // Get the events from database func (s *SQLite) Get(ctx context.Context, id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) { selectStm := `Select seq, id, version, reason, type, timestamp, data, metadata from events where id=? and type=? and version>? order by version asc` rows, err := s.db.QueryContext(ctx, selectStm, id, aggregateType, afterVersion) if err != nil { return nil, err } return &Iterator{Rows: rows}, nil } // All iterate over all event in GlobalEvents order func (s *SQLite) All(start core.Version) core.Fetcher { iter := Iterator{} return func() (core.Iterator, error) { // set start from second call and forward if iter.CurrentGlobalVersion != 0 { start = iter.CurrentGlobalVersion + 1 } selectStm := `Select seq, id, version, reason, type, timestamp, data, metadata from events where seq >= ? order by seq asc` rows, err := s.db.Query(selectStm, start) if err != nil { return nil, err } iter.Rows = rows return &iter, nil } } ================================================ FILE: eventstore/sql/sqlite_test.go ================================================ package sql_test import ( sqldriver "database/sql" "errors" "fmt" "testing" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/eventstore/sql" _ "github.com/mattn/go-sqlite3" ) func TestSuiteSQLite(t *testing.T) { f := func() (core.EventStore, func(), error) { return eventstore(false) } testsuite.Test(t, f) } func TestSuiteSQLiteSingelWriter(t *testing.T) { f := func() (core.EventStore, func(), error) { return eventstore(true) } testsuite.Test(t, f) } func TestFetchFuncAll(t *testing.T) { es, close, err := eventstore(false) if err != nil { t.Fatal(err) } defer close() testsuite.TestFetcher(t, es, es.All(0)) } func eventstore(singelWriter bool) (*sql.SQLite, func(), error) { var es *sql.SQLite db, err := sqldriver.Open("sqlite3", "file::memory:?cache=shared") if err != nil { return nil, nil, errors.New(fmt.Sprintf("could not open database %v", err)) } err = db.Ping() if err != nil { return nil, nil, errors.New(fmt.Sprintf("could not ping database %v", err)) } if singelWriter { es, err = sql.NewSQLiteSingelWriter(db) if err != nil { return nil, nil, err } } else { // to make the concurrent test pass (not have to use this in the sql.OpenWithSingelWriter constructor) db.SetMaxOpenConns(1) es, err = sql.NewSQLite(db) if err != nil { return nil, nil, err } } return es, func() { es.Close() }, nil } ================================================ FILE: eventstore/sql/sqlserver.go ================================================ package sql import ( "context" "database/sql" "errors" "fmt" "sync" "time" "github.com/hallgren/eventsourcing/core" ) const createTableSQLServer = `IF OBJECT_ID('[events]', 'U') IS NULL BEGIN CREATE TABLE [events] ( [seq] INT IDENTITY(1,1) PRIMARY KEY, [id] NVARCHAR(255) NOT NULL, [version] INT, [reason] NVARCHAR(255), [type] NVARCHAR(255), [timestamp] NVARCHAR(255), [data] VARBINARY(MAX), [metadata] VARBINARY(MAX), CONSTRAINT uq_events UNIQUE ([id], [type], [version]) ); END` const indexSQLServer = `IF NOT EXISTS ( SELECT 1 FROM sys.indexes WHERE name = 'id_type' AND object_id = OBJECT_ID('events') ) BEGIN CREATE INDEX id_type ON [events] ([id], [type]); END` var stmSQLServer = []string{ createTableSQLServer, indexSQLServer, } // SQLServer event store handler type SQLServer struct { db *sql.DB lock *sync.Mutex } // NewSQLServer connection to database func NewSQLServer(db *sql.DB) (*SQLServer, error) { if err := migrate(db, stmSQLServer); err != nil { return nil, err } return &SQLServer{ db: db, }, nil } // Close the connection func (s *SQLServer) Close() { s.db.Close() } // Save persists events to the database func (s *SQLServer) Save(events []core.Event) error { // If no event return no error if len(events) == 0 { return nil } if s.lock != nil { // prevent multiple writers s.lock.Lock() defer s.lock.Unlock() } aggregateID := events[0].AggregateID aggregateType := events[0].AggregateType tx, err := s.db.BeginTx(context.Background(), nil) if err != nil { return errors.New(fmt.Sprintf("could not start a write transaction, %v", err)) } defer tx.Rollback() var currentVersion core.Version var version int selectStm := `SELECT TOP 1 version FROM [events] WHERE [id] = @id AND [type] = @type ORDER BY version DESC;` err = tx.QueryRow(selectStm, sql.Named("id", aggregateID), sql.Named("type", aggregateType)).Scan(&version) if err != nil && err != sql.ErrNoRows { return err } else if err == sql.ErrNoRows { // if no events are saved before set the current version to zero currentVersion = core.Version(0) } else { // set the current version to the last event stored currentVersion = core.Version(version) } // Make sure no other has saved event to the same aggregate concurrently if core.Version(currentVersion)+1 != events[0].Version { return core.ErrConcurrency } var lastInsertedID int64 insert := `INSERT INTO [events] (id, version, reason, type, timestamp, data, metadata) OUTPUT INSERTED.seq VALUES (@id, @version, @reason, @type, @timestamp, @data, @metadata);` for i, event := range events { err := tx.QueryRow( insert, sql.Named("id", event.AggregateID), sql.Named("version", event.Version), sql.Named("reason", event.Reason), sql.Named("type", event.AggregateType), sql.Named("timestamp", event.Timestamp.Format(time.RFC3339)), sql.Named("data", event.Data), sql.Named("metadata", event.Metadata), ).Scan(&lastInsertedID) if err != nil { return err } // override the event in the slice exposing the GlobalVersion to the caller events[i].GlobalVersion = core.Version(lastInsertedID) } return tx.Commit() } // Get the events from database func (s *SQLServer) Get(ctx context.Context, id string, aggregateType string, afterVersion core.Version) (core.Iterator, error) { selectStm := `SELECT seq, id, version, reason, type, timestamp, data, metadata FROM [events] WHERE id = @id AND type = @type AND version > @version ORDER BY version ASC;` rows, err := s.db.QueryContext(ctx, selectStm, sql.Named("id", id), sql.Named("type", aggregateType), sql.Named("version", afterVersion)) if err != nil { return nil, err } return &Iterator{Rows: rows}, nil } // All iterate over all event in GlobalEvents order func (s *SQLServer) All(start core.Version) core.Fetcher { iter := Iterator{} return func() (core.Iterator, error) { // set start from second call and forward if iter.CurrentGlobalVersion != 0 { start = iter.CurrentGlobalVersion + 1 } selectStm := `SELECT seq, id, version, reason, type, timestamp, data, metadata FROM [events] WHERE seq >= @start ORDER BY seq ASC;` rows, err := s.db.Query(selectStm, sql.Named("start", start)) if err != nil { return nil, err } iter.Rows = rows return &iter, nil } } ================================================ FILE: eventstore/sql/sqlserver_test.go ================================================ package sql_test import ( "context" gosql "database/sql" "fmt" "testing" "time" _ "github.com/denisenkom/go-mssqldb" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/eventstore/sql" ) func TestSuiteSQLServer(t *testing.T) { dsn, closer, err := sqlServer() if err != nil { t.Fatal(err) } defer closer() f := func() (core.EventStore, func(), error) { es, err := sqlServerConnect(dsn) if err != nil { return nil, nil, err } return es, es.Close, nil } testsuite.Test(t, f) } func TestFetcherAllSQLServer(t *testing.T) { dsn, closer, err := sqlServer() if err != nil { t.Fatal(err) } defer closer() es, err := sqlServerConnect(dsn) if err != nil { t.Fatal(err) } defer es.Close() testsuite.TestFetcher(t, es, es.All(0)) } func sqlServerConnect(dsn string) (*sql.SQLServer, error) { var db *gosql.DB var err error for i := 0; i < 10; i++ { // Connect using database/sql db, err = gosql.Open("sqlserver", dsn) // Test the connection if err == nil && db.Ping() == nil { break } time.Sleep(2 * time.Second) } if err != nil { return nil, err } return sql.NewSQLServer(db) } func sqlServer() (string, func(), error) { ctx := context.Background() // Start MSSQL container req := testcontainers.ContainerRequest{ Image: "mcr.microsoft.com/mssql/server:2019-latest", ExposedPorts: []string{"1433/tcp"}, Env: map[string]string{ "ACCEPT_EULA": "Y", "SA_PASSWORD": "YourStrong(!)Password", }, WaitingFor: wait.ForLog("SQL Server is now ready for client connections").WithStartupTimeout(2 * time.Minute), } mssqlC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return "", nil, err } host, err := mssqlC.Host(ctx) if err != nil { return "", nil, err } port, err := mssqlC.MappedPort(ctx, "1433") if err != nil { return "", nil, err } dsn := fmt.Sprintf("sqlserver://sa:YourStrong(!)Password@%s:%s?database=master", host, port.Port()) return dsn, func() { mssqlC.Terminate(ctx) }, nil } ================================================ FILE: example/README.md ================================================ ## Collection of reference projects * [Ardan labs](https://github.com/hallgren/ardanlabs) - Example when presenting event sourcing for Ardan Labs. * [Internal company presentation](https://github.com/hallgren/kundskapsspridning) - Used in company presentation. * [event-sourcing-tech-talk](https://github.com/kieranajp/event-sourcing-tech-talk) - Made by [kieranajp](https://github.com/kieranajp) ================================================ FILE: example/go.mod ================================================ module github.com/hallgren/eventsourcing/example go 1.22 require github.com/hallgren/eventsourcing v0.8.1 require github.com/hallgren/eventsourcing/core v0.4.0 // indirect // replace github.com/hallgren/eventsourcing => ../. ================================================ FILE: example/go.sum ================================================ github.com/hallgren/eventsourcing v0.8.0 h1:lFMWRdc59+tRwzCzmby8I3qr2WbD1GWqB/ypJrBMbVc= github.com/hallgren/eventsourcing v0.8.0/go.mod h1:62QwV0m8wLQM6SZwtuGn4lwA5UUFozFnALyZvFeX9ps= github.com/hallgren/eventsourcing v0.8.1 h1:SHefx3Wf2K1ylmGBzykSmt4dRVnDTQGYjODiM69c4uU= github.com/hallgren/eventsourcing v0.8.1/go.mod h1:62QwV0m8wLQM6SZwtuGn4lwA5UUFozFnALyZvFeX9ps= github.com/hallgren/eventsourcing/core v0.4.0 h1:a11TT3df7JlrZtIogqbGmLGgmeugRavwD8HrLtW1Uxw= github.com/hallgren/eventsourcing/core v0.4.0/go.mod h1:rgo2kFwNVCb0bzUub5nOPlUYNlFkp1uUQBEQx5fM3Lk= ================================================ FILE: example/order/cmd/main.go ================================================ package main import ( "context" "fmt" "time" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/aggregate" "github.com/hallgren/eventsourcing/eventstore/memory" "github.com/hallgren/eventsourcing/example/order" ) type Order struct { DiscountAmount uint Total uint } func main() { es := memory.Create() aggregate.Register(&order.Order{}) ongoingOrders := make(map[string]*Order) completedCount := 0 moneyMade := 0 totalDiscountAmount := 0 go func() { i := 0 for { o, err := order.Create(100) if err != nil { panic(err) } err = o.AddDiscount(10) if err != nil { panic(err) } // 33% of orders remove discount if i%3 == 0 { o.RemoveDiscount() } err = o.Pay(80) if err != nil { panic(err) } // 25% of orders are not completed if i%4 == 0 { err = o.Pay(o.Outstanding) if err != nil { panic(err) } } err = aggregate.Save(es, o) if err != nil { panic(err) } time.Sleep(time.Second) i++ } }() for { // setup how the projection will handle events and build the read model p := eventsourcing.NewProjection(es.All(0, 3), func(e eventsourcing.Event) error { switch event := e.Data().(type) { // When an order is created add it to an order map case *order.Created: ongoingOrders[e.AggregateID()] = &Order{ Total: event.Total, } // When order is complete add the discount amount to a sum also store the total amount of completed orders case *order.Completed: completedCount++ totalDiscountAmount += int(ongoingOrders[e.AggregateID()].DiscountAmount) // delete the order from the map delete(ongoingOrders, e.AggregateID()) // Store the discount amount in the order in the map case *order.DiscountApplied: ongoingOrders[e.AggregateID()].DiscountAmount = ongoingOrders[e.AggregateID()].Total - event.Total // If the discount is removed reset the discount amount in the orders map case *order.DiscountRemoved: ongoingOrders[e.AggregateID()].DiscountAmount = 0 // If a payment is made store the value case *order.Paid: moneyMade += int(event.Amount) } fmt.Println("active order:", len(ongoingOrders), "completed orders:", completedCount, "money made:", moneyMade, "total discount amount:", totalDiscountAmount) return nil }) p.Run(context.Background(), time.Second*2) } } ================================================ FILE: example/order/order.go ================================================ package order import ( "fmt" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/aggregate" ) type Status string const ( Pending Status = "pending" Complete Status = "complete" ) // Aggregate // Collects the business rules for the Order // // Rules // 1. Multiple discounts is not allowed // 2. Order amount can't be above 500 // 3. Completed order can't be altered // 4. If a payment has been made it's not possible to alter the discount. // Order is the aggregate protecting the state type Order struct { aggregate.Root Status Status Total uint Discount uint Outstanding uint Paid uint } // Transition builds the aggregate state based on the events func (o *Order) Transition(event eventsourcing.Event) { switch e := event.Data().(type) { case *Created: o.Status = Pending o.Total = e.Total o.Outstanding = e.Total case *DiscountApplied: o.Discount = e.Percentage o.Outstanding = e.Total case *DiscountRemoved: o.Discount = 0 o.Outstanding = o.Total case *Paid: o.Outstanding -= e.Amount o.Paid += e.Amount case *Completed: o.Status = Complete } } // Events // Defines all possible events for the Order aggregate // Register is a eventsouring helper function that must be defined on // the aggregate. func (o *Order) Register(r aggregate.RegisterFunc) { r( &Created{}, &DiscountApplied{}, &DiscountRemoved{}, &Paid{}, &Completed{}, ) } // Created when the order was created type Created struct { Total uint } // DiscountApplied when a discount was applied type DiscountApplied struct { Percentage uint Total uint } // DiscountRemoved when the discount was removed type DiscountRemoved struct{} // Paid an amount from the total type Paid struct { Amount uint } // Completed - the order is fully paid type Completed struct{} // Commands // Holds the business logic and protects the aggregate (Order) state. // Events should only be created via commands. // Create creates the initial order func Create(amount uint) (*Order, error) { if amount > 500 { return nil, fmt.Errorf("amount can't be higher than 500") } o := Order{} aggregate.TrackChange(&o, &Created{Total: amount}) return &o, nil } // AddDiscount adds discount to the order func (o *Order) AddDiscount(percentage uint) error { if o.Status == Complete { return fmt.Errorf("can't add discount on completed order") } if o.Discount > 0 { return fmt.Errorf("there is already an active discount") } if o.Paid > 0 { return fmt.Errorf("can't add discount on order with payments") } if percentage > 25 { return fmt.Errorf("discount can't be over 25 was %d", percentage) } // ignore if discount percentage is zero if percentage == 0 { return nil } discountFloat := float64(percentage) / 100.0 newTotal := o.Total - uint(float64(o.Total)*discountFloat) aggregate.TrackChange(o, &DiscountApplied{Percentage: percentage, Total: newTotal}) return nil } // RemoveDiscount removes the discount if any otherwise ignore func (o *Order) RemoveDiscount() { // No discount applied if o.Discount == 0 { return } aggregate.TrackChange(o, &DiscountRemoved{}) return } // Pay creates a payment on the order. If the outstanding amount is zero the order // is paid. func (o *Order) Pay(amount uint) error { if o.Status == Complete { return fmt.Errorf("can't pay on completed order") } if int(o.Outstanding)-int(amount) < 0 { return fmt.Errorf("payment is higher than order total amount") } aggregate.TrackChange(o, &Paid{Amount: amount}) if o.Outstanding == 0 { aggregate.TrackChange(o, &Completed{}) } return nil } ================================================ FILE: example/order/order_test.go ================================================ package order_test import ( "testing" "github.com/hallgren/eventsourcing/example/order" ) func TestCreateOrder(t *testing.T) { o, err := order.Create(1000) if err == nil { t.Fatal("expected error due to for high amount") } o, err = order.Create(100) if err != nil { t.Fatal(err) } if o.Status != order.Pending { t.Fatalf("expected order status to be pending but was %s", o.Status) } if o.Total != 100 { t.Fatalf("expected order total to be 100 but was %d", o.Total) } if o.Outstanding != 100 { t.Fatalf("expected order outstanding to be 100 but was %d", o.Outstanding) } } func TestDiscount(t *testing.T) { o, err := order.Create(100) if err != nil { t.Fatal(err) } err = o.AddDiscount(40) if err == nil { t.Fatal(err) } err = o.AddDiscount(20) if err != nil { t.Fatal(err) } // not possible to add multiple discounts err = o.AddDiscount(4) if err == nil { t.Fatal(err) } if o.Outstanding == o.Total { t.Fatalf("expected outstanding (%d) to be less with a discount but was same as Total (%d)", o.Outstanding, o.Total) } o.RemoveDiscount() if o.Outstanding != o.Total { t.Fatalf("expected order total (%d) to be same as outstandingi (%d)", o.Outstanding, o.Total) } if o.Status != order.Pending { t.Fatalf("order status should be pending but was %s", o.Status) } } func TestPaid(t *testing.T) { o, err := order.Create(100) if err != nil { t.Fatal(err) } err = o.Pay(200) if err == nil { t.Fatal("should not be abel to pay more than total amount") } err = o.Pay(10) if err != nil { t.Fatal(err) } if o.Outstanding != 100-10 { t.Fatalf("expected order outstanding to be 90 but was %d", o.Outstanding) } err = o.Pay(90) if err != nil { t.Fatal(err) } if o.Outstanding != 0 { t.Fatalf("expected outstanding to be zero but was %d", o.Outstanding) } if o.Status != order.Complete { t.Fatalf("expexted status to be complete but was %s", o.Status) } err = o.Pay(10) if err == nil { t.Fatal("should not be able to pay on complated order") } } ================================================ FILE: example/tictactoe/cmd/main/main.go ================================================ package main import ( "fmt" "math/rand" "github.com/hallgren/eventsourcing/aggregate" "github.com/hallgren/eventsourcing/eventstore/memory" "github.com/hallgren/eventsourcing/example/tictactoe" ) func main() { es := memory.Create() aggregate.Register(&tictactoe.Game{}) for i := 0; i < 10; i++ { game := PlayGame() fmt.Printf("game %d\n", i) game.Render() aggregate.Save(es, game) } } func PlayGame() *tictactoe.Game { game := tictactoe.NewGame() for !game.Done() { x := rand.Intn(3) y := rand.Intn(3) game.PlayMove(x, y) } return game } ================================================ FILE: example/tictactoe/tictactoe.go ================================================ package tictactoe import ( "fmt" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/aggregate" ) type Game struct { aggregate.Root board [3][3]string // "X", "O", or "" turn string // "X" or "O" gameOver bool winner string // "X", "O", or "" } // Transition is an method to transform events to game state. The state is then used // to uphold the rules of the game. This method is needed for the Game to be an aggregate // and be used by the functions in the aggregate package. func (g *Game) Transition(event eventsourcing.Event) { switch e := event.Data().(type) { case *Started: g.turn = "X" case *XMoved: g.board[e.X][e.Y] = "X" g.turn = "O" case *OMoved: g.board[e.X][e.Y] = "O" g.turn = "X" case *Draw: g.gameOver = true g.winner = "" case *XWon: g.gameOver = true g.winner = "X" case *OWon: g.gameOver = true g.winner = "O" } } // Register is used to register the events and the aggregate to the internal register. func (g *Game) Register(f aggregate.RegisterFunc) { f(&Started{}, &XMoved{}, &OMoved{}, &Draw{}, &XWon{}, &OWon{}) } // Events // Started indicates that the game has started type Started struct{} // XMoved then the X player moved togheter with the cordinates. type XMoved struct { X int Y int } // OMoved then the O player moved togheter with the cordinates. type OMoved struct { X int Y int } // XWon is the last event when X is the winner type XWon struct{} // OWon is the last event when O is the winner type OWon struct{} // Draw is when either X now O won type Draw struct{} // Constructor func NewGame() *Game { g := Game{} aggregate.TrackChange(&g, &Started{}) return &g } // Query methods func (g *Game) Turn() string { return g.turn } func (g *Game) Done() bool { return g.gameOver } func (g *Game) Winner() string { return g.winner } // Render prints the board func (g *Game) Render() { for i, row := range g.board { for j, cell := range row { if cell == "" { fmt.Print(" ") } else { fmt.Printf(" %s ", cell) } if j < len(row)-1 { fmt.Print("|") } } fmt.Println() if i < len(g.board)-1 { fmt.Println("---+---+---") } } } // Commands func (g *Game) PlayMove(x, y int) error { // verify that the move can be made if g.gameOver { return fmt.Errorf("game over") } if g.board[x][y] != "" { return fmt.Errorf("position already taken") } if g.turn == "X" { aggregate.TrackChange(g, &XMoved{x, y}) } else { aggregate.TrackChange(g, &OMoved{x, y}) } winner := checkWinner(g.board) if winner == "X" { aggregate.TrackChange(g, &XWon{}) return nil } if winner == "O" { aggregate.TrackChange(g, &OWon{}) return nil } if isDraw(g.board) { aggregate.TrackChange(g, &Draw{}) } return nil } // helpers // checkWinner returns "X" or "O" if there's a winner, or "" if none. func checkWinner(board [3][3]string) string { // Check rows and columns for i := 0; i < 3; i++ { if board[i][0] != "" && board[i][0] == board[i][1] && board[i][1] == board[i][2] { return board[i][0] // row win } if board[0][i] != "" && board[0][i] == board[1][i] && board[1][i] == board[2][i] { return board[0][i] // column win } } // Check diagonals if board[0][0] != "" && board[0][0] == board[1][1] && board[1][1] == board[2][2] { return board[0][0] } if board[0][2] != "" && board[0][2] == board[1][1] && board[1][1] == board[2][0] { return board[0][2] } // No winner return "" } func isDraw(board [3][3]string) bool { if checkWinner(board) != "" { return false } for _, row := range board { for _, cell := range row { if cell == "" { return false } } } return true } ================================================ FILE: example/tictactoe/tictactoe_test.go ================================================ package tictactoe_test import ( "testing" "github.com/hallgren/eventsourcing/example/tictactoe" ) func TestValidMove(t *testing.T) { game := tictactoe.NewGame() err := game.PlayMove(0, 0) if err != nil { t.Errorf("Expected valid move, got error: %v", err) } // verify the events if len(game.Events()) != 2 { t.Fatalf("expected two events got %d", len(game.Events())) } if game.Events()[0].Reason() != "Started" { t.Fatalf("expected first event to be started was %v", game.Events()[0].Reason()) } switch e := game.Events()[1].Data().(type) { case *tictactoe.XMoved: if e.X != 0 && e.Y != 0 { t.Fatalf("Expected 'X' at 0,0, got %d,%d", e.X, e.Y) } default: t.Fatal("expeted XMoved event") } } func TestInvalidMoveAlreadyTaken(t *testing.T) { game := tictactoe.NewGame() _ = game.PlayMove(0, 0) err := game.PlayMove(0, 0) if err == nil { t.Errorf("Expected error for move on occupied square, got nil") } } func TestTurnSwitching(t *testing.T) { game := tictactoe.NewGame() _ = game.PlayMove(0, 0) if game.Turn() != "O" { t.Errorf("Expected turn to switch to O, got %s", game.Turn()) } } func TestWinDetection(t *testing.T) { game := tictactoe.NewGame() game.PlayMove(0, 0) game.PlayMove(1, 0) game.PlayMove(0, 1) game.PlayMove(1, 1) game.PlayMove(0, 2) // X wins if !game.Done() || game.Winner() != "X" { t.Errorf("Expected X to win, got GameOver=%v, Winner=%s", game.Done(), game.Winner()) } // make sure the last event is XWon l := len(game.Events()) switch game.Events()[l-1].Data().(type) { case *tictactoe.XWon: default: t.Fatalf("expected last event to be XWon but was %v", game.Events()[l-1].Reason()) } } func TestDrawDetection(t *testing.T) { game := tictactoe.NewGame() // first row game.PlayMove(0, 0) // X game.PlayMove(0, 1) // O game.PlayMove(0, 2) // X // second row game.PlayMove(1, 1) // O game.PlayMove(1, 0) // X game.PlayMove(1, 2) // O // third row game.PlayMove(2, 1) // X game.PlayMove(2, 0) // O _ = game.PlayMove(2, 2) // X if !game.Done() || game.Winner() != "" { t.Errorf("Expected draw, got GameOver=%v, Winner=%s", game.Done(), game.Winner()) } // make sure the last event is Draw l := len(game.Events()) switch game.Events()[l-1].Data().(type) { case *tictactoe.Draw: default: t.Fatalf("expected last event to be Draw but was %v", game.Events()[l-1].Reason()) } } ================================================ FILE: go.mod ================================================ module github.com/hallgren/eventsourcing go 1.19 require github.com/hallgren/eventsourcing/core v0.5.2 // replace github.com/hallgren/eventsourcing/core => ./core ================================================ FILE: go.sum ================================================ github.com/hallgren/eventsourcing/core v0.5.2 h1:knvM1jP0zziiybce+Au7ysYvZQnDwxkV+/RFZWNDMiw= github.com/hallgren/eventsourcing/core v0.5.2/go.mod h1:rgo2kFwNVCb0bzUub5nOPlUYNlFkp1uUQBEQx5fM3Lk= ================================================ FILE: internal/encoderjson.go ================================================ package internal import "encoding/json" type EncoderJSON struct{} func (e EncoderJSON) Serialize(v interface{}) ([]byte, error) { return json.Marshal(v) } func (e EncoderJSON) Deserialize(data []byte, v interface{}) error { return json.Unmarshal(data, v) } type encoder interface { Serialize(v interface{}) ([]byte, error) Deserialize(data []byte, v interface{}) error } // global encoder used for events var EventEncoder encoder = EncoderJSON{} // global encoder used for snapshots var SnapshotEncoder encoder = EncoderJSON{} ================================================ FILE: internal/register.go ================================================ package internal import ( "reflect" "github.com/hallgren/eventsourcing/core" ) type registerFunc = func() interface{} type register struct { eventsF map[string]registerFunc aggregates map[string]struct{} } // Aggregate interface to use the aggregate root specific methods type aggregate interface { Register(func(events ...interface{})) } // GlobalRegister keeps track of registered aggregates and events var GlobalRegister = newRegister() // ResetRegister reset the event regsiter func ResetRegister() { GlobalRegister = newRegister() } func newRegister() *register { return ®ister{ eventsF: make(map[string]registerFunc), aggregates: make(map[string]struct{}), } } // aggregateRegistered return true if the aggregate is registered func (r *register) AggregateRegistered(a aggregate) bool { typ := aggregateType(a) _, ok := r.aggregates[typ] return ok } // EventRegistered return the func to generate the correct event data type and true if it exists // otherwise false. func (r *register) EventRegistered(event core.Event) (registerFunc, bool) { d, ok := r.eventsF[event.AggregateType+"_"+event.Reason] return d, ok } // Register store the aggregate and calls the aggregate method Register to Register the aggregate events. func (r *register) Register(a aggregate) { typ := reflect.TypeOf(a).Elem().Name() fu := r.RegisterAggregate(typ) a.Register(fu) } func (r *register) RegisterAggregate(aggregateType string) func(events ...interface{}) { r.aggregates[aggregateType] = struct{}{} // fe is a helper function to make the event type registration simpler fe := func(events ...interface{}) []registerFunc { res := []registerFunc{} for _, e := range events { res = append(res, eventToFunc(e)) } return res } return func(events ...interface{}) { eventsF := fe(events...) for _, f := range eventsF { event := f() reason := reflect.TypeOf(event).Elem().Name() r.eventsF[aggregateType+"_"+reason] = f } } } func eventToFunc(event interface{}) registerFunc { return func() interface{} { // return a new instance of the event return reflect.New(reflect.TypeOf(event).Elem()).Interface() } } func aggregateType(a aggregate) string { return reflect.TypeOf(a).Elem().Name() } ================================================ FILE: iterator.go ================================================ package eventsourcing import ( "fmt" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/internal" ) // Iterator to stream events to reduce memory foot print type Iterator struct { CoreIterator core.Iterator } // Close the underlaying iterator func (i *Iterator) Close() { i.CoreIterator.Close() } func (i *Iterator) Next() bool { return i.CoreIterator.Next() } func (i *Iterator) Value() (Event, error) { event, err := i.CoreIterator.Value() if err != nil { return Event{}, err } // apply the event to the aggregate f, found := internal.GlobalRegister.EventRegistered(event) if !found { return Event{}, fmt.Errorf("event not registered, aggregate type: %s, reason: %s, global version: %d, %w", event.AggregateType, event.Reason, event.GlobalVersion, ErrEventNotRegistered) } data := f() err = internal.EventEncoder.Deserialize(event.Data, &data) if err != nil { return Event{}, err } metadata := make(map[string]interface{}) if event.Metadata != nil { err = internal.EventEncoder.Deserialize(event.Metadata, &metadata) if err != nil { return Event{}, err } } return Event{ event: event, data: data, metadata: metadata, }, nil } ================================================ FILE: projections.go ================================================ package eventsourcing import ( "context" "errors" "sync" "sync/atomic" "time" "github.com/hallgren/eventsourcing/core" ) type callbackFunc func(e Event) error // ErrProjectionAlreadyRunning is returned if Run is called on an already running projection var ErrProjectionAlreadyRunning = errors.New("projection is already running") type Projection struct { running atomic.Bool fetchF core.Fetcher callbackF callbackFunc trigger chan func() Strict bool // Strict indicate if the projection should return error if the event it fetches is not found in the register Name string } // ProjectionGroup runs projections concurrently type ProjectionGroup struct { Pace time.Duration // Pace is used when a projection is running and it reaches the end of the event stream projections []*Projection cancelF context.CancelFunc wg sync.WaitGroup ErrChan chan error } // ProjectionResult is the return type for a Group and Race type ProjectionResult struct { Error error Name string LastHandledEvent Event } // Projection creates a projection that will run down an event stream func NewProjection(fetchF core.Fetcher, callbackF callbackFunc) *Projection { projection := Projection{ fetchF: fetchF, callbackF: callbackF, trigger: make(chan func()), Strict: true, // Default strict is active } return &projection } // TriggerAsync force a running projection to run immediately independent on the pace // It will return immediately after triggering the prjection to run. // If the trigger channel is already filled it will return without inserting any value. func (p *Projection) TriggerAsync() { if !p.running.Load() { return } select { case p.trigger <- func() {}: default: } } // TriggerSync force a running projection to run immediately independent on the pace // It will wait for the projection to finish running to its current end before returning. func (p *Projection) TriggerSync() { if !p.running.Load() { return } wg := sync.WaitGroup{} wg.Add(1) f := func() { wg.Done() } p.trigger <- f wg.Wait() } // Run runs the projection forever until the context is cancelled. When there are no more events to consume it // waits for a trigger or context cancel. func (p *Projection) Run(ctx context.Context, pace time.Duration) error { if p.running.Load() { return ErrProjectionAlreadyRunning } p.running.Store(true) defer func() { p.running.Store(false) }() var noopFunc = func() {} var f = noopFunc triggerFunc := func() { f() // reset the f to the noop func f = noopFunc } for { result := p.RunToEnd(ctx) // if triggered by a sync trigger the triggerFunc callback that it's finished // if not triggered by a sync trigger the triggerFunc will call an no ops function triggerFunc() if result.Error != nil { return result.Error } select { case <-ctx.Done(): return ctx.Err() case <-time.After(pace): case f = <-p.trigger: } } } // RunToEnd runs until the projection reaches the end of the event stream func (p *Projection) RunToEnd(ctx context.Context) ProjectionResult { var result ProjectionResult var lastHandledEvent Event for { select { case <-ctx.Done(): return ProjectionResult{Error: ctx.Err(), Name: result.Name, LastHandledEvent: result.LastHandledEvent} default: ran, result := p.RunOnce() // if the first event returned error or if it did not run at all if result.LastHandledEvent.GlobalVersion() == 0 { result.LastHandledEvent = lastHandledEvent } if result.Error != nil { return result } // hit the end of the event stream if !ran { return result } lastHandledEvent = result.LastHandledEvent } } } // RunOnce runs the fetch method one time func (p *Projection) RunOnce() (bool, ProjectionResult) { // ran indicate if there were events to fetch var ran bool var lastHandledEvent Event coreIterator, err := p.fetchF() if err != nil { return false, ProjectionResult{Error: err, Name: p.Name, LastHandledEvent: lastHandledEvent} } iterator := &Iterator{ CoreIterator: coreIterator, } defer iterator.Close() for iterator.Next() { ran = true event, err := iterator.Value() if err != nil { if errors.Is(err, ErrEventNotRegistered) { if p.Strict { return false, ProjectionResult{Error: err, Name: p.Name, LastHandledEvent: lastHandledEvent} } continue } return false, ProjectionResult{Error: err, Name: p.Name, LastHandledEvent: lastHandledEvent} } err = p.callbackF(event) if err != nil { return false, ProjectionResult{Error: err, Name: p.Name, LastHandledEvent: lastHandledEvent} } // keep a reference to the last successfully handled event lastHandledEvent = event } return ran, ProjectionResult{Error: nil, Name: p.Name, LastHandledEvent: lastHandledEvent} } // Group runs a group of projections concurrently func NewProjectionGroup(projections ...*Projection) *ProjectionGroup { return &ProjectionGroup{ projections: projections, cancelF: func() {}, Pace: time.Second * 10, // Default pace 10 seconds } } // Start starts all projectinos in the group, an error channel i created on the group to notify // if a result containing an error is returned from a projection func (g *ProjectionGroup) Start() { g.ErrChan = make(chan error) ctx, cancel := context.WithCancel(context.Background()) g.cancelF = cancel g.wg.Add(len(g.projections)) for _, projection := range g.projections { go func(p *Projection) { defer g.wg.Done() err := p.Run(ctx, g.Pace) if !errors.Is(err, context.Canceled) { g.ErrChan <- err } }(projection) } } // TriggerAsync force all projections to run not waiting for them to finish func (g *ProjectionGroup) TriggerAsync() { for _, projection := range g.projections { projection.TriggerAsync() } } // TriggerSync force all projections to run and wait for them to finish func (g *ProjectionGroup) TriggerSync() { wg := sync.WaitGroup{} for _, projection := range g.projections { wg.Add(1) go func(p *Projection) { p.TriggerSync() wg.Done() }(projection) } wg.Wait() } // Stop halts all projections in the group func (g *ProjectionGroup) Stop() { if g.ErrChan == nil { return } g.cancelF() // return when all projections has stopped g.wg.Wait() // close the error channel close(g.ErrChan) g.ErrChan = nil } // ProjectionsRace runs the projections to the end of the events streams. // Can be used on a stale event stream with no more events coming in or when you want to know when all projections are done. func ProjectionsRace(cancelOnError bool, projections ...*Projection) ([]ProjectionResult, error) { var lock sync.Mutex var causingErr error ctx, cancel := context.WithCancel(context.Background()) defer cancel() wg := sync.WaitGroup{} wg.Add(len(projections)) results := make([]ProjectionResult, len(projections)) for i, projection := range projections { go func(pr *Projection, index int) { defer wg.Done() result := pr.RunToEnd(ctx) if result.Error != nil { if !errors.Is(result.Error, context.Canceled) && cancelOnError { cancel() lock.Lock() causingErr = result.Error lock.Unlock() } } lock.Lock() results[index] = result lock.Unlock() }(projection, i) } wg.Wait() return results, causingErr } ================================================ FILE: projections_test.go ================================================ package eventsourcing_test import ( "context" "encoding/json" "errors" "sync" "testing" "time" "github.com/hallgren/eventsourcing" "github.com/hallgren/eventsourcing/aggregate" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/eventstore/memory" "github.com/hallgren/eventsourcing/internal" ) // Person aggregate type Person struct { aggregate.Root Name string Age int Dead int } // Born event type Born struct { Name string } // AgedOneYear event type AgedOneYear struct { } // CreatePerson constructor for the Person func CreatePerson(name string) (*Person, error) { if name == "" { return nil, errors.New("name can't be blank") } person := Person{} aggregate.TrackChange(&person, &Born{Name: name}) return &person, nil } // Transition the person state dependent on the events func (person *Person) Transition(event eventsourcing.Event) { switch e := event.Data().(type) { case *Born: person.Age = 0 person.Name = e.Name case *AgedOneYear: person.Age += 1 } } // Register bind the events to the repository when the aggregate is registered. func (person *Person) Register(f aggregate.RegisterFunc) { f(&Born{}, &AgedOneYear{}) } // GrowOlder command func (person *Person) GrowOlder() { metaData := make(map[string]interface{}) metaData["foo"] = "bar" aggregate.TrackChangeWithMetadata(person, &AgedOneYear{}, metaData) } func createPersonEvent(es *memory.Memory, name string, age int) error { person, err := CreatePerson(name) if err != nil { return err } for i := 0; i < age; i++ { person.GrowOlder() } events := make([]core.Event, 0) for _, e := range person.Events() { data, err := json.Marshal(e.Data()) if err != nil { return err } events = append(events, core.Event{ AggregateID: e.AggregateID(), Reason: e.Reason(), AggregateType: e.AggregateType(), Version: core.Version(e.Version()), GlobalVersion: core.Version(e.GlobalVersion()), Timestamp: e.Timestamp(), Data: data, }) } return es.Save(events) } func TestRunOnce(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) projectedName := "" err := createPersonEvent(es, "kalle", 0) if err != nil { t.Fatal(err) } err = createPersonEvent(es, "anka", 0) if err != nil { t.Fatal(err) } // run projection one event at each run proj := eventsourcing.NewProjection(es.All(0, 1), func(event eventsourcing.Event) error { switch e := event.Data().(type) { case *Born: projectedName = e.Name } return nil }) // should set projectedName to kalle work, result := proj.RunOnce() if result.Error != nil { t.Fatal(result) } if !work { t.Fatal("there was no work to do") } if projectedName != "kalle" { t.Fatalf("expected %q was %q", "kalle", projectedName) } // should set the projected name to anka work, result = proj.RunOnce() if result.Error != nil { t.Fatal(result.Error) } if !work { t.Fatal("there was no work to do") } if projectedName != "anka" { t.Fatalf("expected %q was %q", "anka", projectedName) } } func TestRun(t *testing.T) { es := memory.Create() aggregate.Register(&Person{}) projectedName := "" sourceName := "kalle" err := createPersonEvent(es, sourceName, 1) if err != nil { t.Fatal(err) } // run projection proj := eventsourcing.NewProjection(es.All(0, 1), func(event eventsourcing.Event) error { switch e := event.Data().(type) { case *Born: projectedName = e.Name } return nil }) ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Second)) defer cancel() // will run once then sleep for 10 seconds err = proj.Run(ctx, time.Second*10) if !errors.Is(err, context.DeadlineExceeded) { t.Fatal(err) } if projectedName != sourceName { t.Fatalf("expected %q was %q", sourceName, projectedName) } } func TestRunSameProjectionConcurrently(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) sourceName := "kalle" err := createPersonEvent(es, sourceName, 0) if err != nil { t.Fatal(err) } wg := sync.WaitGroup{} wg.Add(1) // run projection proj := eventsourcing.NewProjection(es.All(0, 1), func(event eventsourcing.Event) error { wg.Done() return nil }) ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Second)) defer cancel() // Run the projection go func() { proj.Run(ctx, time.Second*10) }() // wait to make sure the projection is already running wg.Wait() err = proj.Run(ctx, time.Second*10) if !errors.Is(err, eventsourcing.ErrProjectionAlreadyRunning) { t.Fatal(err) } } func TestTriggerSync(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) projectedName := "" sourceName := "kalle" // run projection proj := eventsourcing.NewProjection(es.All(0, 1), func(event eventsourcing.Event) error { switch e := event.Data().(type) { case *Born: projectedName = e.Name } return nil }) group := eventsourcing.NewProjectionGroup(proj) group.Start() defer group.Stop() // make sure the projection has finished it's first round time.Sleep(time.Millisecond * 10) // create the event after the projection is started as the projection would have consume it. err := createPersonEvent(es, sourceName, 1) if err != nil { t.Fatal(err) } // check projection is not updated before trigger if projectedName == sourceName { t.Fatalf("expected projected name to differ: %q was %q", sourceName, projectedName) } // trigger the projection group.TriggerSync() // check that the projected value is updated if projectedName != sourceName { t.Fatalf("expected projected name: %q was %q", sourceName, projectedName) } } func TestTriggerAsync(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) projectedName := "" sourceName := "kalle" wg := sync.WaitGroup{} wg.Add(1) // run projection proj := eventsourcing.NewProjection(es.All(0, 1), func(event eventsourcing.Event) error { switch e := event.Data().(type) { case *Born: projectedName = e.Name } wg.Done() return nil }) group := eventsourcing.NewProjectionGroup(proj) group.Start() defer group.Stop() // make sure the projection has finished it's first round time.Sleep(time.Millisecond * 10) // create the event after the projection is started as the projection would have consume it. err := createPersonEvent(es, sourceName, 0) if err != nil { t.Fatal(err) } // check projection is not updated before trigger if projectedName == sourceName { t.Fatalf("expected projected name to differ: %q was %q", sourceName, projectedName) } // trigger the projection group.TriggerAsync() group.TriggerAsync() group.TriggerAsync() // wait until the async trigger has finished wg.Wait() // check that the projected value is updated if projectedName != sourceName { t.Fatalf("expected projected name: %q was %q", sourceName, projectedName) } } func TestCloseEmptyGroup(t *testing.T) { g := eventsourcing.NewProjectionGroup() g.Stop() g.Start() g.Stop() g.Stop() } func TestStartMultipleProjections(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) // callback that handles the events callbackF := func(event eventsourcing.Event) error { return nil } r1 := eventsourcing.NewProjection(es.All(0, 1), callbackF) r2 := eventsourcing.NewProjection(es.All(0, 1), callbackF) r3 := eventsourcing.NewProjection(es.All(0, 1), callbackF) g := eventsourcing.NewProjectionGroup(r1, r2, r3) g.Start() g.Stop() } func TestErrorFromCallback(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) err := createPersonEvent(es, "kalle", 1) if err != nil { t.Fatal(err) } // define application error that can be returned from the callback function var ErrApplication = errors.New("application error") // callback that handles the events callbackF := func(event eventsourcing.Event) error { return ErrApplication } r := eventsourcing.NewProjection(es.All(0, 1), callbackF) g := eventsourcing.NewProjectionGroup(r) g.Start() defer g.Stop() select { case err = <-g.ErrChan: case <-time.After(time.Second): t.Fatal("test timed out") } if !errors.Is(err, ErrApplication) { if err != nil { t.Fatalf("expected application error but got %s", err.Error()) } t.Fatal("got none error expected ErrApplication") } } func TestStrict(t *testing.T) { // setup es := memory.Create() internal.ResetRegister() // We do not register the Person aggregate with the Born event attached err := createPersonEvent(es, "kalle", 1) if err != nil { t.Fatal(err) } proj := eventsourcing.NewProjection(es.All(0, 1), func(event eventsourcing.Event) error { return nil }) _, result := proj.RunOnce() if !errors.Is(result.Error, eventsourcing.ErrEventNotRegistered) { t.Fatalf("expected ErrEventNotRegistered got %q", err.Error()) } } func TestRace(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) err := createPersonEvent(es, "kalle", 50) if err != nil { t.Fatal(err) } // callback that handles the events callbackF := func(event eventsourcing.Event) error { time.Sleep(time.Millisecond * 2) return nil } applicationErr := errors.New("an error") r1 := eventsourcing.NewProjection(es.All(0, 1), callbackF) r2 := eventsourcing.NewProjection(es.All(0, 1), func(e eventsourcing.Event) error { time.Sleep(time.Millisecond) if e.GlobalVersion() == 31 { return applicationErr } return nil }) result, err := eventsourcing.ProjectionsRace(true, r1, r2) // causing err should be applicationErr if !errors.Is(err, applicationErr) { t.Fatalf("expected causing error to be applicationErr got %v", err) } // projection 0 should have a context.Canceled error if !errors.Is(result[0].Error, context.Canceled) { t.Fatalf("expected projection %q to have err 'context.Canceled' got %v", result[0].Name, result[0].Error) } // projection 1 should have a applicationErr error if !errors.Is(result[1].Error, applicationErr) { t.Fatalf("expected projection %q to have err 'applicationErr' got %v", result[1].Name, result[1].Error) } // projection 1 should have halted on event with GlobalVersion 30 if result[1].LastHandledEvent.GlobalVersion() != 30 { t.Fatalf("expected projection 1 Event.GlobalVersion() to be 30 but was %d", result[1].LastHandledEvent.GlobalVersion()) } } func TestKeepStartPosition(t *testing.T) { // setup es := memory.Create() aggregate.Register(&Person{}) err := createPersonEvent(es, "kalle", 5) if err != nil { t.Fatal(err) } start := core.Version(0) counter := 0 // callback that handles the events callbackF := func(event eventsourcing.Event) error { switch event.Data().(type) { case *AgedOneYear: counter++ } start = core.Version(event.GlobalVersion() + 1) return nil } r := eventsourcing.NewProjection(es.All(0, 1), callbackF) _, err = eventsourcing.ProjectionsRace(true, r) if err != nil { t.Fatal(err) } err = createPersonEvent(es, "anka", 5) if err != nil { t.Fatal(err) } _, err = eventsourcing.ProjectionsRace(true, r) if err != nil { t.Fatal(err) } // Born 2 + AgedOnYear 5 + 5 = 12 + Next Event 1 = 13 if start != 13 { t.Fatalf("expected start to be 13 was %d", start) } if counter != 10 { t.Fatalf("expected counter to be 10 was %d", counter) } } ================================================ FILE: snapshotstore/memory/memory.go ================================================ package memory import ( "context" "github.com/hallgren/eventsourcing/core" ) type Memory struct { snapshots map[string]core.Snapshot } // Create in memory snapshot store func Create() *Memory { return &Memory{ snapshots: make(map[string]core.Snapshot), } } func (m *Memory) Close() { } func (m *Memory) Get(ctx context.Context, aggregateID, aggregateType string) (core.Snapshot, error) { snapshot, ok := m.snapshots[aggregateType+"_"+aggregateID] if !ok { return core.Snapshot{}, core.ErrSnapshotNotFound } return snapshot, nil } func (m *Memory) Save(snapshot core.Snapshot) error { m.snapshots[snapshot.Type+"_"+snapshot.ID] = snapshot return nil } ================================================ FILE: snapshotstore/memory/memory_test.go ================================================ package memory_test import ( "testing" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/snapshotstore/memory" ) func TestSuite(t *testing.T) { f := func() (core.SnapshotStore, func(), error) { ss := memory.Create() return ss, func() { ss.Close() }, nil } testsuite.TestSnapshotStore(t, f) } ================================================ FILE: snapshotstore/sql/README.md ================================================ # SQL Snapshot Store The sql is a module containing multiple sql based snapshot stores that are all based on the database/sql interface in go standard library. The different snapshot stores has specific database schemas that support the different databases. ## SQLite Supports the SQLite database https://www.sqlite.org/ ### Database Schema ```go CREATE TABLE IF NOT EXISTS snapshots ( id VARCHAR NOT NULL, type VARCHAR, version INTEGER, global_version INTEGER, state BLOB ); CREATE UNIQUE INDEX IF NOT EXISTS id_type ON snapshots (id, type); ``` ### Constructor ```go // NewSQLite connection to database func NewSQLite(db *sql.DB) (*SQLite, error) { ``` ### Example of use ```go import ( sqldriver "database/sql" "github.com/hallgren/eventsourcing/snapshotstore/sql" _ "github.com/mattn/go-sqlite3" ) db, err := sqldriver.Open("sqlite3", "file::memory:?locked.sqlite?cache=shared") if err != nil { return nil, nil, err } sqliteSnapshotStore, err := sql.NewSQLite(db) if err != nil { return nil, nil, err } ``` ## Postgres Supports the Postgres database https://www.postgresql.org ### Database Schema ```go CREATE TABLE IF NOT EXISTS snapshots ( id VARCHAR NOT NULL, type VARCHAR, version INTEGER, global_version INTEGER, state BYTEA ); CREATE UNIQUE INDEX IF NOT EXISTS id_type ON snapshots (id, type); ``` ### Constructor ```go // NewPostgres connection to database func NewPostgres(db *sql.DB) (*Postgres, error) { ``` ### Example of use ```go import ( gosql "database/sql" _ "github.com/lib/pq" "github.com/hallgren/eventsourcing/snapshotstore/sql" ) db, err := gosql.Open("postgres", dsn) if err != nil { return err } postgreSnapshotStore, err := sql.NewPostgres(db) if err != nil { return err } ``` ================================================ FILE: snapshotstore/sql/go.mod ================================================ module github.com/hallgren/eventsourcing/snapshotstore/sql go 1.25.0 require ( github.com/hallgren/eventsourcing/core v0.5.2 github.com/lib/pq v1.12.3 github.com/mattn/go-sqlite3 v1.14.42 github.com/testcontainers/testcontainers-go v0.42.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect github.com/moby/moby/client v0.4.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/sys v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) // replace github.com/hallgren/eventsourcing/core => ../../core ================================================ FILE: snapshotstore/sql/go.sum ================================================ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hallgren/eventsourcing/core v0.5.2 h1:knvM1jP0zziiybce+Au7ysYvZQnDwxkV+/RFZWNDMiw= github.com/hallgren/eventsourcing/core v0.5.2/go.mod h1:rgo2kFwNVCb0bzUub5nOPlUYNlFkp1uUQBEQx5fM3Lk= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= ================================================ FILE: snapshotstore/sql/migrate.go ================================================ package sql import ( "context" "database/sql" ) func migrate(db *sql.DB, stm []string) error { tx, err := db.BeginTx(context.Background(), nil) if err != nil { return err } defer tx.Rollback() for _, b := range stm { _, err := tx.Exec(b) if err != nil { return err } } return tx.Commit() } ================================================ FILE: snapshotstore/sql/postgres.go ================================================ package sql import ( "context" "database/sql" "errors" "fmt" "github.com/hallgren/eventsourcing/core" ) const createTablePostgres = `CREATE TABLE IF NOT EXISTS snapshots ( id VARCHAR NOT NULL, type VARCHAR, version INTEGER, global_version INTEGER, state BYTEA );` const createIndexPostgres = `CREATE UNIQUE INDEX IF NOT EXISTS id_type ON snapshots (id, type);` type Postgres struct { db *sql.DB } // NewPostgres connection to database func NewPostgres(db *sql.DB) (*Postgres, error) { if err := migrate(db, []string{ createTablePostgres, createIndexPostgres, }); err != nil { return nil, err } return &Postgres{ db: db, }, nil } // Close the connection func (s *Postgres) Close() { s.db.Close() } // Save persists the snapshot func (s *Postgres) Save(snapshot core.Snapshot) error { tx, err := s.db.BeginTx(context.Background(), nil) if err != nil { return errors.New(fmt.Sprintf("could not start a write transaction, %v", err)) } defer tx.Rollback() statement := `SELECT id from snapshots where id=$1 AND type=$2 LIMIT 1` var id string err = tx.QueryRow(statement, snapshot.ID, snapshot.Type).Scan(&id) if err != nil && err != sql.ErrNoRows { return err } if err == sql.ErrNoRows { // insert statement = `INSERT INTO snapshots (state, id, type, version, global_version) VALUES ($1, $2, $3, $4, $5)` _, err = tx.Exec(statement, string(snapshot.State), snapshot.ID, snapshot.Type, snapshot.Version, snapshot.GlobalVersion) if err != nil { return err } } else { // update statement = `UPDATE snapshots set state=$1, version=$2, global_version=$3 where id=$4 AND type=$5` _, err = tx.Exec(statement, string(snapshot.State), snapshot.Version, snapshot.GlobalVersion, snapshot.ID, snapshot.Type) if err != nil { return err } } return tx.Commit() } // Get return the snapshot data from the database func (s *Postgres) Get(ctx context.Context, aggregateID, aggregateType string) (core.Snapshot, error) { var globalVersion core.Version var version core.Version var state []byte selectStm := `Select version, global_version, state from snapshots where id=$1 and type=$2` row := s.db.QueryRow(selectStm, aggregateID, aggregateType) if row.Err() != nil { return core.Snapshot{}, row.Err() } err := row.Scan(&version, &globalVersion, &state) if err != nil && errors.Is(err, sql.ErrNoRows) { return core.Snapshot{}, core.ErrSnapshotNotFound } else if err != nil { return core.Snapshot{}, err } return core.Snapshot{ ID: aggregateID, Type: aggregateType, Version: version, GlobalVersion: globalVersion, State: state, }, nil } ================================================ FILE: snapshotstore/sql/postgres_test.go ================================================ package sql_test import ( "context" gosql "database/sql" "fmt" "testing" "time" _ "github.com/lib/pq" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/snapshotstore/sql" ) func TestSuitePostgres(t *testing.T) { ctx := context.Background() // Set up the PostgreSQL container request req := testcontainers.ContainerRequest{ Image: "postgres:16", // Use a specific version ExposedPorts: []string{"5432/tcp"}, Env: map[string]string{ "POSTGRES_USER": "test", "POSTGRES_PASSWORD": "secret", "POSTGRES_DB": "testdb", }, WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(30 * time.Second), } // Start the container postgresContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { t.Fatalf("failed to start container: %v", err) } defer postgresContainer.Terminate(ctx) // Get container host and port host, _ := postgresContainer.Host(ctx) port, _ := postgresContainer.MappedPort(ctx, "5432") // Build the DSN dsn := fmt.Sprintf("host=%s port=%s user=test password=secret dbname=testdb sslmode=disable", host, port.Port()) f := func() (core.SnapshotStore, func(), error) { // Connect using database/sql db, err := gosql.Open("postgres", dsn) if err != nil { return nil, nil, fmt.Errorf("db open failed: %w", err) } // Test the connection err = db.Ping() if err != nil { return nil, nil, err } es, err := sql.NewPostgres(db) if err != nil { t.Fatal(err) } return es, func() { db.Close() }, nil } testsuite.TestSnapshotStore(t, f) } ================================================ FILE: snapshotstore/sql/sqlite.go ================================================ package sql import ( "context" "database/sql" "errors" "fmt" "github.com/hallgren/eventsourcing/core" ) const createTableSQLite = ` CREATE TABLE IF NOT EXISTS snapshots ( id VARCHAR NOT NULL, type VARCHAR, version INTEGER, global_version INTEGER, state BLOB );` const createIndexSQLite = `CREATE UNIQUE INDEX IF NOT EXISTS id_type ON snapshots (id, type);` type SQLite struct { db *sql.DB } // NewSQLite connection to database func NewSQLite(db *sql.DB) (*SQLite, error) { if err := migrate(db, []string{ createTableSQLite, createIndexSQLite, }); err != nil { return nil, err } return &SQLite{ db: db, }, nil } // Close the connection func (s *SQLite) Close() { s.db.Close() } // Save persists the snapshot func (s *SQLite) Save(snapshot core.Snapshot) error { tx, err := s.db.BeginTx(context.Background(), nil) if err != nil { return errors.New(fmt.Sprintf("could not start a write transaction, %v", err)) } defer tx.Rollback() statement := `SELECT id from snapshots where id=$1 AND type=$2 LIMIT 1` var id string err = tx.QueryRow(statement, snapshot.ID, snapshot.Type).Scan(&id) if err != nil && err != sql.ErrNoRows { return err } if err == sql.ErrNoRows { // insert statement = `INSERT INTO snapshots (state, id, type, version, global_version) VALUES ($1, $2, $3, $4, $5)` _, err = tx.Exec(statement, string(snapshot.State), snapshot.ID, snapshot.Type, snapshot.Version, snapshot.GlobalVersion) if err != nil { return err } } else { // update statement = `UPDATE snapshots set state=$1, version=$2, global_version=$3 where id=$4 AND type=$5` _, err = tx.Exec(statement, string(snapshot.State), snapshot.Version, snapshot.GlobalVersion, snapshot.ID, snapshot.Type) if err != nil { return err } } return tx.Commit() } // Get return the snapshot data from the database func (s *SQLite) Get(ctx context.Context, aggregateID, aggregateType string) (core.Snapshot, error) { var globalVersion core.Version var version core.Version var state []byte selectStm := `Select version, global_version, state from snapshots where id=? and type=?` row := s.db.QueryRow(selectStm, aggregateID, aggregateType) if row.Err() != nil { return core.Snapshot{}, row.Err() } err := row.Scan(&version, &globalVersion, &state) if err != nil && errors.Is(err, sql.ErrNoRows) { return core.Snapshot{}, core.ErrSnapshotNotFound } else if err != nil { return core.Snapshot{}, err } return core.Snapshot{ ID: aggregateID, Type: aggregateType, Version: version, GlobalVersion: globalVersion, State: state, }, nil } ================================================ FILE: snapshotstore/sql/sqlite_test.go ================================================ package sql_test import ( sqldriver "database/sql" "testing" "github.com/hallgren/eventsourcing/core" "github.com/hallgren/eventsourcing/core/testsuite" "github.com/hallgren/eventsourcing/snapshotstore/sql" _ "github.com/mattn/go-sqlite3" ) func TestSuite(t *testing.T) { f := func() (core.SnapshotStore, func(), error) { return snapshotstore() } testsuite.TestSnapshotStore(t, f) } func snapshotstore() (*sql.SQLite, func(), error) { db, err := sqldriver.Open("sqlite3", "file::memory:?locked.sqlite?cache=shared") if err != nil { return nil, nil, err } db.SetMaxOpenConns(1) err = db.Ping() if err != nil { return nil, nil, err } store, err := sql.NewSQLite(db) if err != nil { return nil, nil, err } return store, func() { store.Close() }, nil }