pg_eventstore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +90 -0
- data/db/extensions.sql +2 -0
- data/db/indexes.sql +13 -0
- data/db/primary_and_foreign_keys.sql +11 -0
- data/db/tables.sql +21 -0
- data/docs/appending_events.md +170 -0
- data/docs/configuration.md +82 -0
- data/docs/events_and_streams.md +45 -0
- data/docs/multiple_commands.md +46 -0
- data/docs/reading_events.md +161 -0
- data/docs/writing_middleware.md +160 -0
- data/lib/pg_eventstore/abstract_command.rb +18 -0
- data/lib/pg_eventstore/client.rb +133 -0
- data/lib/pg_eventstore/commands/append.rb +61 -0
- data/lib/pg_eventstore/commands/multiple.rb +14 -0
- data/lib/pg_eventstore/commands/read.rb +24 -0
- data/lib/pg_eventstore/commands.rb +6 -0
- data/lib/pg_eventstore/config.rb +30 -0
- data/lib/pg_eventstore/connection.rb +97 -0
- data/lib/pg_eventstore/errors.rb +107 -0
- data/lib/pg_eventstore/event.rb +59 -0
- data/lib/pg_eventstore/event_class_resolver.rb +17 -0
- data/lib/pg_eventstore/event_serializer.rb +27 -0
- data/lib/pg_eventstore/extensions/options_extension.rb +103 -0
- data/lib/pg_eventstore/middleware.rb +15 -0
- data/lib/pg_eventstore/pg_result_deserializer.rb +48 -0
- data/lib/pg_eventstore/queries.rb +127 -0
- data/lib/pg_eventstore/query_builders/events_filtering_query.rb +187 -0
- data/lib/pg_eventstore/rspec/has_option_matcher.rb +90 -0
- data/lib/pg_eventstore/sql_builder.rb +126 -0
- data/lib/pg_eventstore/stream.rb +72 -0
- data/lib/pg_eventstore/tasks/setup.rake +37 -0
- data/lib/pg_eventstore/version.rb +5 -0
- data/lib/pg_eventstore.rb +85 -0
- data/pg_eventstore.gemspec +40 -0
- metadata +113 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 330df61b6d77cd8211f42750b42eaef7165c66a22fc1f33a6a138248fe8beb9b
|
4
|
+
data.tar.gz: 021b611a40e2392020600ad4650467e14113295bd332de5d42a16f2354e38561
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 200fd40463fe2fc715c0680dff345a333c39aa1ef280b2654df9f69973ab5c34d0698eb7d6ef473b04bd1085b0c646ca05910157e8c1f22d639bfcd30b29d3ad
|
7
|
+
data.tar.gz: 1e059c402affcac9ab5666e2263c953c27bdb9648c75dfe9a16bfa942c16cbd4429e3b1bc2fc717ee8281167a241d3be89a5e67da7f93e2bf3c9ec6d82056793
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
6
|
+
|
7
|
+
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
8
|
+
|
9
|
+
## Our Standards
|
10
|
+
|
11
|
+
Examples of behavior that contributes to a positive environment for our community include:
|
12
|
+
|
13
|
+
* Demonstrating empathy and kindness toward other people
|
14
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
15
|
+
* Giving and gracefully accepting constructive feedback
|
16
|
+
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
17
|
+
* Focusing on what is best not just for us as individuals, but for the overall community
|
18
|
+
|
19
|
+
Examples of unacceptable behavior include:
|
20
|
+
|
21
|
+
* The use of sexualized language or imagery, and sexual attention or
|
22
|
+
advances of any kind
|
23
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
24
|
+
* Public or private harassment
|
25
|
+
* Publishing others' private information, such as a physical or email
|
26
|
+
address, without their explicit permission
|
27
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
28
|
+
professional setting
|
29
|
+
|
30
|
+
## Enforcement Responsibilities
|
31
|
+
|
32
|
+
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
33
|
+
|
34
|
+
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
35
|
+
|
36
|
+
## Scope
|
37
|
+
|
38
|
+
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
39
|
+
|
40
|
+
## Enforcement
|
41
|
+
|
42
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at intale.a@gmail.com. All complaints will be reviewed and investigated promptly and fairly.
|
43
|
+
|
44
|
+
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
45
|
+
|
46
|
+
## Enforcement Guidelines
|
47
|
+
|
48
|
+
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
49
|
+
|
50
|
+
### 1. Correction
|
51
|
+
|
52
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
53
|
+
|
54
|
+
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
55
|
+
|
56
|
+
### 2. Warning
|
57
|
+
|
58
|
+
**Community Impact**: A violation through a single incident or series of actions.
|
59
|
+
|
60
|
+
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
61
|
+
|
62
|
+
### 3. Temporary Ban
|
63
|
+
|
64
|
+
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
65
|
+
|
66
|
+
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
67
|
+
|
68
|
+
### 4. Permanent Ban
|
69
|
+
|
70
|
+
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
71
|
+
|
72
|
+
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
73
|
+
|
74
|
+
## Attribution
|
75
|
+
|
76
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
77
|
+
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
78
|
+
|
79
|
+
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
80
|
+
|
81
|
+
[homepage]: https://www.contributor-covenant.org
|
82
|
+
|
83
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
84
|
+
https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2023 Ivan Dzyzenko
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
# PgEventstore
|
2
|
+
|
3
|
+
Implements database and API to store and read events in event sourced systems.
|
4
|
+
|
5
|
+
## Requirements
|
6
|
+
|
7
|
+
`pg_eventstore` requires a PostgreSQL database with jsonb data type support (which means you need to have v9.2+). However it is recommended to use a non [EOL](https://www.postgresql.org/support/versioning/) PostgreSQL version, because the development of this gem is targeted at current PostgreSQL versions.
|
8
|
+
`pg_eventstore` requires ruby v3+. The development of this gem is targeted at [current](https://endoflife.date/ruby) ruby versions.
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
Install the gem and add to the application's Gemfile by executing:
|
13
|
+
|
14
|
+
$ bundle add pg_eventstore
|
15
|
+
|
16
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
17
|
+
|
18
|
+
$ gem install pg_eventstore
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
Before you start, make sure you created a database where events will be stored. A PostgreSQL user must be a superuser to be able to create tables, indexes, primary/foreign keys, etc. Please don't use an existing database/user for this purpose. Example of creating such database and user:
|
23
|
+
|
24
|
+
```bash
|
25
|
+
sudo -u postgres createuser pg_eventstore --superuser
|
26
|
+
sudo -u postgres psql --command="CREATE DATABASE eventstore OWNER pg_eventstore"
|
27
|
+
sudo -u postgres psql --command="CREATE DATABASE eventstore OWNER pg_eventstore"
|
28
|
+
```
|
29
|
+
|
30
|
+
If necessary - adjust your `pg_hba.conf` to allow `pg_eventstore` user to connect to your PostgreSQL server.
|
31
|
+
|
32
|
+
Next step will be configuring a db connection. Please check the **Configuration** chapter bellow to find out how to do it.
|
33
|
+
|
34
|
+
After the db connection is configured, it is time to create necessary database objects. Please include this line into your `Rakefile`:
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
load "pg_eventstore/tasks/setup.rake"
|
38
|
+
```
|
39
|
+
|
40
|
+
This will include necessary rake tasks. You can now run
|
41
|
+
```bash
|
42
|
+
bundle exec rake pg_eventstore:create
|
43
|
+
```
|
44
|
+
to create necessary database objects. After this step your `pg_eventstore` is ready to use.
|
45
|
+
|
46
|
+
Documentation chapters:
|
47
|
+
|
48
|
+
- [Configuration](docs/configuration.md)
|
49
|
+
- [Events and streams definitions](docs/events_and_streams.md)
|
50
|
+
- [Appending events](docs/appending_events.md)
|
51
|
+
- [Reading events](docs/reading_events.md)
|
52
|
+
- [Writing middlewares](docs/writing_middleware.md)
|
53
|
+
- [How to make multiple commands atomic](docs/multiple_commands.md)
|
54
|
+
|
55
|
+
## Development
|
56
|
+
|
57
|
+
After checking out the repo, run:
|
58
|
+
- `bundle` to install dependencies
|
59
|
+
- `docker-compose up` to start dev/test services
|
60
|
+
- `bin/setup_db` to create/re-create development and test databases, tables and related objects
|
61
|
+
|
62
|
+
Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
63
|
+
|
64
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
65
|
+
|
66
|
+
### Benchmarks
|
67
|
+
|
68
|
+
There is a script to help you to tests the `pg_eventstore` implementation performance. You can run it using next command:
|
69
|
+
|
70
|
+
```bash
|
71
|
+
./benchmark/run
|
72
|
+
```
|
73
|
+
|
74
|
+
### Publishing new version
|
75
|
+
|
76
|
+
1. Push commit with updated `version.rb` file to the `release` branch. The new version will be automatically pushed to [rubygems](https://rubygems.org).
|
77
|
+
2. Create release on GitHub.
|
78
|
+
3. Update `CHANGELOG.md`
|
79
|
+
|
80
|
+
## Contributing
|
81
|
+
|
82
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/yousty/pg_eventstore. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/yousty/pg_eventstore/blob/master/CODE_OF_CONDUCT.md).
|
83
|
+
|
84
|
+
## License
|
85
|
+
|
86
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
87
|
+
|
88
|
+
## Code of Conduct
|
89
|
+
|
90
|
+
Everyone interacting in the PgEventstore project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/yousty/pg_eventstore/blob/master/CODE_OF_CONDUCT.md).
|
data/db/extensions.sql
ADDED
data/db/indexes.sql
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
CREATE UNIQUE INDEX idx_streams_context_and_stream_name_and_stream_id ON public.streams USING btree (context, stream_name, stream_id);
|
2
|
+
-- This index is used when searching by the specific stream and event's types
|
3
|
+
CREATE INDEX idx_events_stream_id_and_revision_and_type ON public.events USING btree (stream_id, stream_revision, type);
|
4
|
+
|
5
|
+
-- This index is used when searching by "all" stream using stream's attributes(context, stream_name, stream_id) and
|
6
|
+
-- event's types. PG's query planner picks this index when none of the given event's type exist
|
7
|
+
CREATE INDEX idx_events_type_and_stream_id_and_position ON public.events USING btree (type, stream_id, global_position);
|
8
|
+
|
9
|
+
-- This index is used when searching by "all" stream using stream's attributes(context, stream_name, stream_id) and
|
10
|
+
-- event's types. PG's query planner picks this index when some of the given event's types exist
|
11
|
+
CREATE INDEX idx_events_position_and_type ON public.events USING btree (global_position, type);
|
12
|
+
|
13
|
+
CREATE INDEX idx_events_link_id ON public.events USING btree (link_id);
|
@@ -0,0 +1,11 @@
|
|
1
|
+
ALTER TABLE ONLY public.streams
|
2
|
+
ADD CONSTRAINT streams_pkey PRIMARY KEY (id);
|
3
|
+
ALTER TABLE ONLY public.events
|
4
|
+
ADD CONSTRAINT events_pkey PRIMARY KEY (id);
|
5
|
+
|
6
|
+
ALTER TABLE ONLY public.events
|
7
|
+
ADD CONSTRAINT events_stream_fk FOREIGN KEY (stream_id)
|
8
|
+
REFERENCES public.streams (id) ON DELETE CASCADE;
|
9
|
+
ALTER TABLE ONLY public.events
|
10
|
+
ADD CONSTRAINT events_link_fk FOREIGN KEY (link_id)
|
11
|
+
REFERENCES public.events (id) ON DELETE CASCADE;
|
data/db/tables.sql
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
CREATE TABLE public.streams
|
2
|
+
(
|
3
|
+
id bigserial NOT NULL,
|
4
|
+
context character varying NOT NULL,
|
5
|
+
stream_name character varying NOT NULL,
|
6
|
+
stream_id character varying NOT NULL,
|
7
|
+
stream_revision int DEFAULT -1 NOT NULL
|
8
|
+
);
|
9
|
+
|
10
|
+
CREATE TABLE public.events
|
11
|
+
(
|
12
|
+
id uuid NOT NULL DEFAULT public.gen_random_uuid(),
|
13
|
+
stream_id bigint NOT NULL,
|
14
|
+
type character varying NOT NULL,
|
15
|
+
global_position bigserial NOT NULL,
|
16
|
+
stream_revision int NOT NULL,
|
17
|
+
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
18
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
19
|
+
link_id uuid,
|
20
|
+
created_at timestamp without time zone NOT NULL DEFAULT now()
|
21
|
+
);
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# Appending Events
|
2
|
+
|
3
|
+
## Append your first event
|
4
|
+
|
5
|
+
The easiest way to append an event is to create an event object and a stream object and call the client's `#append_to_stream` method.
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
require 'securerandom'
|
9
|
+
|
10
|
+
class SomethingHappened < PgEventstore::Event
|
11
|
+
end
|
12
|
+
|
13
|
+
event = SomethingHappened.new(data: { user_id: SecureRandom.uuid, title: "Something happened" })
|
14
|
+
stream = PgEventstore::Stream.new(context: 'MyAwesomeContext', stream_name: 'SomeStream', stream_id: 'f37b82f2-4152-424d-ab6b-0cc6f0a53aae')
|
15
|
+
PgEventstore.client.append_to_stream(stream, event)
|
16
|
+
# => #<SomethingHappened:0x0 @context="MyAwesomeContext", @created_at=2023-11-30 14:47:31.296229 UTC, @data={"title"=>"Something happened", "user_id"=>"be52a81c-ad5b-4cfd-a039-0b7276974e6b"}, @global_position=7, @id="0b01137b-bdd8-4f0d-8ccf-f8c959e3a324", @link_id=nil, @metadata={}, @stream_id="f37b82f2-4152-424d-ab6b-0cc6f0a53aae", @stream_name="SomeStream", @stream_revision=0, @type="SomethingHappened">
|
17
|
+
```
|
18
|
+
|
19
|
+
## Appending multiple events
|
20
|
+
|
21
|
+
You can pass an array of events to the `#append_to_stream` method. This way events will be appended one-by-one. **This operation is atomic and it guarantees that events are added to the stream in the given order.**
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
require 'securerandom'
|
25
|
+
|
26
|
+
class SomethingHappened < PgEventstore::Event
|
27
|
+
end
|
28
|
+
|
29
|
+
event1 = SomethingHappened.new(data: { user_id: SecureRandom.uuid, title: "Something happened 1" })
|
30
|
+
event2 = SomethingHappened.new(data: { user_id: SecureRandom.uuid, title: "Something happened 2" })
|
31
|
+
stream = PgEventstore::Stream.new(context: 'MyAwesomeContext', stream_name: 'SomeStream', stream_id: 'f37b82f2-4152-424d-ab6b-0cc6f0a53aae')
|
32
|
+
PgEventstore.client.append_to_stream(stream, [event1, event2])
|
33
|
+
```
|
34
|
+
|
35
|
+
### Duplicated event id
|
36
|
+
|
37
|
+
If two events with the same id are appended to any stream - `pg_eventstore` will only append one event, and the second command will raise an error.
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
class SomethingHappened < PgEventstore::Event
|
41
|
+
end
|
42
|
+
|
43
|
+
event = SomethingHappened.new(id: SecureRandom.uuid)
|
44
|
+
stream = PgEventstore::Stream.new(context: 'MyAwesomeContext', stream_name: 'SomeStream', stream_id: 'f37b82f2-4152-424d-ab6b-0cc6f0a53aae')
|
45
|
+
PgEventstore.client.append_to_stream(stream, event)
|
46
|
+
# Raises PG::UniqueViolation error
|
47
|
+
PgEventstore.client.append_to_stream(stream, event)
|
48
|
+
```
|
49
|
+
|
50
|
+
## Handling concurrency
|
51
|
+
|
52
|
+
When appending events to a stream you can supply a stream state or stream revision. You can use this to tell `pg_eventstore` what state or version you expect the stream to be in when you append. If the stream isn't in that state then an exception will be thrown.
|
53
|
+
|
54
|
+
For example if we try to append two records expecting both times that the stream doesn't exist we will get an exception on the second:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
require 'securerandom'
|
58
|
+
|
59
|
+
class SomethingHappened < PgEventstore::Event
|
60
|
+
end
|
61
|
+
|
62
|
+
event1 = SomethingHappened.new(data: { foo: :bar })
|
63
|
+
event2 = SomethingHappened.new(data: { bar: :baz })
|
64
|
+
stream = PgEventstore::Stream.new(context: 'MyAwesomeContext', stream_name: 'SomeStream', stream_id: SecureRandom.uuid)
|
65
|
+
|
66
|
+
# Successfully appends an event
|
67
|
+
PgEventstore.client.append_to_stream(stream, event1, options: { expected_revision: :no_stream })
|
68
|
+
# Raises PgEventstore::WrongExpectedRevisionError error
|
69
|
+
PgEventstore.client.append_to_stream(stream, event2, options: { expected_revision: :no_stream })
|
70
|
+
```
|
71
|
+
|
72
|
+
Here are possible values of `:expected_revision` option:
|
73
|
+
|
74
|
+
- `:any`. Doesn't perform any checks. This is the default.
|
75
|
+
- `:no_stream`. Expects a stream to be absent when appending an event
|
76
|
+
- `:stream_exists`. Expects a stream to be present when appending an event
|
77
|
+
- a revision number(Integer). Expects a stream to be in the given revision.
|
78
|
+
|
79
|
+
This check can be used to implement optimistic concurrency. When you retrieve a stream, you take note of the current version number, then when you save it back you can determine if somebody else has modified the record in the meantime.
|
80
|
+
|
81
|
+
```ruby
|
82
|
+
require 'securerandom'
|
83
|
+
|
84
|
+
class SomethingHappened < PgEventstore::Event
|
85
|
+
end
|
86
|
+
|
87
|
+
stream = PgEventstore::Stream.new(context: 'MyAwesomeContext', stream_name: 'SomeStream', stream_id: SecureRandom.uuid)
|
88
|
+
event1 = SomethingHappened.new(data: { foo: :bar })
|
89
|
+
event2 = SomethingHappened.new(data: { bar: :baz })
|
90
|
+
|
91
|
+
# Pre-populate stream with some event
|
92
|
+
PgEventstore.client.append_to_stream(stream, event1)
|
93
|
+
# Get the revision number of latest event
|
94
|
+
revision = PgEventstore.client.read(stream, options: { max_count: 1, direction: 'Backwards' }).first.stream_revision
|
95
|
+
# Expected revision matches => will succeed
|
96
|
+
PgEventstore.client.append_to_stream(stream, event2, options: { expected_revision: revision })
|
97
|
+
# Will fail with PgEventstore::WrongExpectedRevisionError error, because stream version is 1 now, but :expected_revision
|
98
|
+
# option is 0
|
99
|
+
PgEventstore.client.append_to_stream(stream, event2, options: { expected_revision: revision })
|
100
|
+
```
|
101
|
+
|
102
|
+
### What to do when a PgEventstore::WrongExpectedRevisionError error is risen?
|
103
|
+
|
104
|
+
Imagine the following scenario:
|
105
|
+
1. You load events of a stream to build the state of your business object represented by the stream.
|
106
|
+
2. You check your business rules to see if you can change that object's state the way you want to change it.
|
107
|
+
3. If no business rules have been violated, you have the go to publish the event representing the state change.
|
108
|
+
4. To make sure the new event will follow the last event you used to build your object state, you retrieve that last event's revision and increase it by one. You now have the expected revision for the event to be published.
|
109
|
+
5. You publish the event but retrieve a `WrongExpectedRevisionError`. This means another process has appended an event to the same stream, after you were loading your business object, while you were checking your business rules.
|
110
|
+
6. Now you need to repeat the process: load your business objects from the updated events stream, apply your business rules and if there is still no violation, try to append the event with the updated stream revision. You can do this procedure until the event is published or a maximum number of retries has been reached.
|
111
|
+
|
112
|
+
The following example shows the described retry procedure, with a simple business rule that does not allow adding an event after a `UserRemoved` event:
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
require 'securerandom'
|
116
|
+
class UserAboutMeChanged < PgEventstore::Event
|
117
|
+
end
|
118
|
+
|
119
|
+
class UserRemoved < PgEventstore::Event
|
120
|
+
end
|
121
|
+
|
122
|
+
def latest_event(stream)
|
123
|
+
PgEventstore.client.read(stream, options: { max_count: 1, direction: 'Backwards' }).first
|
124
|
+
rescue PgEventstore::StreamNotFoundError
|
125
|
+
end
|
126
|
+
|
127
|
+
def publish_event(stream, event)
|
128
|
+
retries_count = 0
|
129
|
+
begin
|
130
|
+
last_event = latest_event(stream)
|
131
|
+
# Ensure that the last event is not 'UserRemoved' event
|
132
|
+
return if last_event&.type == 'UserRemoved'
|
133
|
+
|
134
|
+
PgEventstore.client.append_to_stream(stream, event, options: { expected_revision: last_event&.stream_revision })
|
135
|
+
rescue PgEventstore::WrongExpectedRevisionError => e
|
136
|
+
# Parallel process has appended another event after we read the latest event, but before we appended our event. Such
|
137
|
+
# scenarios can be safely retried.
|
138
|
+
retries_count += 1
|
139
|
+
raise if retries_count > 3
|
140
|
+
retry
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
stream = PgEventstore::Stream.new(context: 'UserProfile', stream_name: 'User', stream_id: SecureRandom.uuid)
|
145
|
+
event = UserAboutMeChanged.new(data: { user_id: '123', about_me: 'hi there!' })
|
146
|
+
|
147
|
+
publish_event(stream, event)
|
148
|
+
```
|
149
|
+
|
150
|
+
## Middlewares
|
151
|
+
|
152
|
+
If you would like to skip some of your registered middlewares from processing events before they get appended to a stream - you should use the `:middlewares` argument which allows you to override the list of middlewares you would like to use.
|
153
|
+
|
154
|
+
Let's say you have these registered middlewares:
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
PgEventstore.configure do |config|
|
158
|
+
config.middlewares = { foo: FooMiddleware.new, bar: BarMiddleware.new, baz: BazMiddleware.new }
|
159
|
+
end
|
160
|
+
```
|
161
|
+
|
162
|
+
And you want to skip `FooMiddleware` and `BazMiddleware`. You simply have to provide an array of corresponding middleware keys you would like to use:
|
163
|
+
|
164
|
+
```ruby
|
165
|
+
event = PgEventstore::Event.new
|
166
|
+
stream = PgEventstore::Stream.new(context: 'MyAwesomeContext', stream_name: 'SomeStream', stream_id: 'f37b82f2-4152-424d-ab6b-0cc6f0a53aae')
|
167
|
+
PgEventstore.client.append_to_stream(stream, event, middlewares: %i[bar])
|
168
|
+
```
|
169
|
+
|
170
|
+
See [Writing middleware](writing_middleware.md) chapter for info about what is middleware and how to implement it.
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# Configuration
|
2
|
+
|
3
|
+
Configuration options:
|
4
|
+
|
5
|
+
| name | value | default value | description |
|
6
|
+
|-------------------------|----------|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
7
|
+
| pg_uri | String | `'postgresql://postgres:postgres@localhost:5432/eventstore'` | PostgreSQL connection string. See PostgreSQL [docs](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS) for more information. |
|
8
|
+
| max_count | Integer | `1000` | Number of events to return in one response when reading from a stream. |
|
9
|
+
| middlewares | Array | `{}` | A hash where a key is a name of your middleware and value is an object that respond to `#serialize` and `#deserialize` methods. See [**Writing middleware**](writing_middleware.md) chapter. |
|
10
|
+
| event_class_resolver | `#call` | `PgEventstore::EventClassResolver.new` | A `#call`-able object that accepts a string and returns an event's class. See **Resolving events classes** chapter bellow for more info. |
|
11
|
+
| logger | `Logger` | `nil` | A logger that logs messages from `pg_eventstore` gem. |
|
12
|
+
| connection_pool_size | Integer | `5` | Max number of connections per ruby process. It must equal the number of threads of your application. |
|
13
|
+
| connection_pool_timeout | Integer | `5` | Time in seconds to wait for the connection in pool to be released. If no connections are available during this time - `ConnectionPool::TimeoutError` will be raised. See `connection_pool` gem [docs](https://github.com/mperham/connection_pool#usage) for more info. |
|
14
|
+
|
15
|
+
## Multiple configurations
|
16
|
+
|
17
|
+
`pg_eventstore` allows you to have as many configs as you want. This allows you, for example, to have different
|
18
|
+
databases, or to have a different set of middlewares for specific cases only. To do so, you have to name your
|
19
|
+
configuration, and later provide that name to `PgEventstore` client.
|
20
|
+
|
21
|
+
Setup your configs:
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
PgEventstore.configure(name: :pg_db_1) do |config|
|
25
|
+
# adjust your config here
|
26
|
+
config.pg_uri = 'postgresql://postgres:postgres@localhost:5432/eventstore'
|
27
|
+
end
|
28
|
+
PgEventstore.configure(name: :pg_db_2) do |config|
|
29
|
+
# adjust your second config here
|
30
|
+
config.pg_uri = 'postgresql://postgres:postgres@localhost:5532/eventstore'
|
31
|
+
end
|
32
|
+
```
|
33
|
+
|
34
|
+
Tell `PgEventstore` which config you want to use:
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
# Read from "all" stream using :pg_db_1 config
|
38
|
+
PgEventstore.client(:pg_db_1).read(PgEventstore::Stream.all_stream)
|
39
|
+
# Read from "all" stream using :pg_db_2 config
|
40
|
+
PgEventstore.client(:pg_db_2).read(PgEventstore::Stream.all_stream)
|
41
|
+
```
|
42
|
+
|
43
|
+
### Default config
|
44
|
+
|
45
|
+
If you have one config only - you don't have to bother naming it or passing a config name to the client when performing
|
46
|
+
any operations. You can configure it as usual.
|
47
|
+
|
48
|
+
Setup your default config:
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
PgEventstore.configure do |config|
|
52
|
+
# config goes here
|
53
|
+
config.pg_uri = 'postgresql://postgres:postgres@localhost:5432/eventstore'
|
54
|
+
end
|
55
|
+
```
|
56
|
+
|
57
|
+
Use it:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
|
61
|
+
# Read from "all" stream using your default config
|
62
|
+
EventStoreClient.client.read(PgEventstore::Stream.all_stream)
|
63
|
+
```
|
64
|
+
|
65
|
+
## Resolving event classes
|
66
|
+
|
67
|
+
During the deserialization process `pg_eventstore` tries to pick the correct class for an event. By default it does it
|
68
|
+
using the `PgEventstore::EventClassResolver` class. All it does is `Object.const_get(event_type)`. By default, if you
|
69
|
+
don't provide the `type` attribute for an event explicitly, it will grab the event's class name, meaning by default:
|
70
|
+
|
71
|
+
- event's type is event's class name
|
72
|
+
- when instantiating an event - `pg_eventstore` tries to lookup an event class based on the value of event's `type`
|
73
|
+
attribute with a fallback to `PgEventstore::Event` class
|
74
|
+
|
75
|
+
You can override the default event class resolver by providing any `#call`-able object. It should accept event's type
|
76
|
+
and return event's class based on it. Example:
|
77
|
+
|
78
|
+
```ruby
|
79
|
+
PgEventstore.configure do |config|
|
80
|
+
config.event_class_resolver = proc { |event_type| Object.const_get(event_type.gsub('Foo', 'Bar')) rescue PgEventstore::Event }
|
81
|
+
end
|
82
|
+
```
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# The description of Event and Stream definitions
|
2
|
+
|
3
|
+
`pg_eventstore` provides classes to prepare the events to be inserted into the eventstore. The most important are:
|
4
|
+
|
5
|
+
- `PgEventstore::Event` class which represents an event object
|
6
|
+
- `PgEventstore::Stream` class which represents a stream object
|
7
|
+
|
8
|
+
## Event object and its defaults
|
9
|
+
|
10
|
+
`PgEventstore::Event` has the following attributes:
|
11
|
+
|
12
|
+
- `id` - String(UUIDv4, optional, not `nil`). If no provided - the value will be autogenerated.
|
13
|
+
- `type` - String(optional, not `nil`). Default is an event's class name. Types which start from `$` indicate system events. It is not recommended to prefix your events types with `$` sign.
|
14
|
+
- `global_position` - Integer(optional, read only). Event's global position in the eventstore, aka the "all" stream position (inspired by the popular EventstoreDB). Manually assigning this attribute has no effect. It is internally set when reading events from the database.
|
15
|
+
- `stream` - PgEventstore::Stream(optional, read only). A Stream an event belongs to, see description below. Manually assigning this attribute has no effect. It is internally set when appending an event to the given stream or when reading events from the database.
|
16
|
+
- `stream_revision` - Integer(optional, read only). A revision of an event inside its stream.
|
17
|
+
- `data` - Hash(optional). Event's payload data. For example, if you have a `DescriptionChanged` event class, then you may want to have a description value in the event payload data. Example: `DescriptionChanged.new(data: { 'description' => 'Description of something', 'post_id' => SecureRandom.uuid })`
|
18
|
+
- `metadata` - Hash(optional). Event metadata. Event meta information which is not part of an events data payload. Example: `{ published_by: publishing_user.id }`
|
19
|
+
- `link_id` - String(UUIDv4, optional, read only). If an event is a link event (link events are pointers to other events), this attribute contains the `id` of the original event. Manually assigning this attribute has no effect. It is internally set when appending an event to the given stream or when reading events from the database.
|
20
|
+
- `created_at` - Time(optional, read only). Database's timestamp when an event was appended to a stream. You may want to put your own timestamp into a `metadata` attribute - it may be useful when migrating between different databases. Manually assigning this attribute has no effect. It is internally set when appending an event to the given stream or when reading events from the database.
|
21
|
+
|
22
|
+
Example:
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
PgEventstore::Event.new(data: { 'foo' => 'bar' })
|
26
|
+
```
|
27
|
+
|
28
|
+
## Stream object
|
29
|
+
|
30
|
+
To be able to manipulate a stream, you have to compute a stream's object first. It can be achieved by using the `PgEventstore::Stream` class. Here is a description of its attributes:
|
31
|
+
|
32
|
+
- `context` - String(required). A Bounded Context, read more [here](https://martinfowler.com/bliki/BoundedContext.html). Values which start from `$` sign are reserved by `pg_eventstore`. Such contexts can't be used to append events.
|
33
|
+
- `stream_name` - String(required). A stream name.
|
34
|
+
- `stream_id` - String(required). A stream id.
|
35
|
+
- `id` - Integer(optional, read only). Internal id. It is set when a stream is returned from the database as part of the deserialization process. Manually assigning this attribute has no effect.
|
36
|
+
- `stream_revision` - Integer(optional, read only). Current stream's revision. You can rely on this value when setting the `:expected_revision` option when appending events to a stream. It is set when a stream is returned from the database a part of the deserialization process. Manually assigning this attribute has no effect.
|
37
|
+
|
38
|
+
Example:
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
PgEventstore::Stream.new(context: 'Sales', stream_name: 'Customer', stream_id: '1')
|
42
|
+
PgEventstore::Stream.new(context: 'Sales', stream_name: 'Customer', stream_id: 'f37b82f2-4152-424d-ab6b-0cc6f0a53aae')
|
43
|
+
```
|
44
|
+
|
45
|
+
There is a special stream, called the "all" stream. You can get this object by calling the`PgEventstore::Stream.all_stream` method. Read more about the "all" stream in the `Reading from the "all" stream` section of [Reading events](reading_events.md) chapter.
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# Multiple commands
|
2
|
+
|
3
|
+
`pg_eventstore` implements the `#multiple` method to allow you to make several different commands atomic. Example:
|
4
|
+
|
5
|
+
```ruby
|
6
|
+
PgEventstore.client.multiple do
|
7
|
+
unless PgEventstore.client.read(stream3, options: { max_count: 1, direction: 'Backwards' }).last&.type == 'Removed'
|
8
|
+
PgEventstore.client.append_to_stream(stream1, event1)
|
9
|
+
PgEventstore.client.append_to_stream(stream2, event2)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
```
|
13
|
+
|
14
|
+
All commands inside a `multiple` block either all succeed or all fail. This allows you to easily implement complex business rules. However, it comes with a price of performance. The more you put in a single block, the higher the chance it will have conflicts with other commands run in parallel, increasing overall time to complete. **Because of this performance implications, do not put more events than needed in a `multple` block.** You may still want to use it though as it could simplify your implementation.
|
15
|
+
|
16
|
+
**Please take into account that due to concurrency of parallel commands, a block of code may be re-run several times before succeeding.** So, if you put any piece of code besides `pg_evenstore`'s commands - make sure it is ready for re-runs. A good and a bad examples:
|
17
|
+
|
18
|
+
## Bad
|
19
|
+
|
20
|
+
```ruby
|
21
|
+
PgEventstore.client.multiple do
|
22
|
+
old_email = PgEventstore.client.read(user_stream, options: { filter: { event_types: ['UserEmailChanged'] }, max_count: 1, direction: 'Backwards' }).first&.data&.dig('email')
|
23
|
+
# Email hasn't changed - prevent publishing unnecessary changes
|
24
|
+
next if old_email == user.email
|
25
|
+
|
26
|
+
PgEventstore.client.append_to_stream(user_stream, UserEmailChanged.new(data: { email: user.email }))
|
27
|
+
# This is the mistake. UserMailer.notify_email_changed may be triggered several times
|
28
|
+
UserMailer.notify_email_changed(user.id, old_email: old_email, new_email: user.email).deliver_later
|
29
|
+
end
|
30
|
+
```
|
31
|
+
|
32
|
+
## Good
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
old_email =
|
36
|
+
PgEventstore.client.multiple do
|
37
|
+
old_email = PgEventstore.client.read(user_stream, options: { filter: { event_types: ['UserEmailChanged'] }, max_count: 1, direction: 'Backwards' }).first&.data&.dig('email')
|
38
|
+
# Email hasn't changed - prevent publishing unnecessary changes
|
39
|
+
next if old_email == user.email
|
40
|
+
|
41
|
+
PgEventstore.client.append_to_stream(user_stream, UserEmailChanged.new(data: { email: user.email }))
|
42
|
+
old_email
|
43
|
+
end
|
44
|
+
# Sending email outside multiple block to prevent potential re-triggering of it
|
45
|
+
UserMailer.notify_email_changed(user.id, old_email: old_email, new_email: user.email).deliver_later
|
46
|
+
```
|