pg_eventstore 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/CODE_OF_CONDUCT.md +84 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +90 -0
  6. data/db/extensions.sql +2 -0
  7. data/db/indexes.sql +13 -0
  8. data/db/primary_and_foreign_keys.sql +11 -0
  9. data/db/tables.sql +21 -0
  10. data/docs/appending_events.md +170 -0
  11. data/docs/configuration.md +82 -0
  12. data/docs/events_and_streams.md +45 -0
  13. data/docs/multiple_commands.md +46 -0
  14. data/docs/reading_events.md +161 -0
  15. data/docs/writing_middleware.md +160 -0
  16. data/lib/pg_eventstore/abstract_command.rb +18 -0
  17. data/lib/pg_eventstore/client.rb +133 -0
  18. data/lib/pg_eventstore/commands/append.rb +61 -0
  19. data/lib/pg_eventstore/commands/multiple.rb +14 -0
  20. data/lib/pg_eventstore/commands/read.rb +24 -0
  21. data/lib/pg_eventstore/commands.rb +6 -0
  22. data/lib/pg_eventstore/config.rb +30 -0
  23. data/lib/pg_eventstore/connection.rb +97 -0
  24. data/lib/pg_eventstore/errors.rb +107 -0
  25. data/lib/pg_eventstore/event.rb +59 -0
  26. data/lib/pg_eventstore/event_class_resolver.rb +17 -0
  27. data/lib/pg_eventstore/event_serializer.rb +27 -0
  28. data/lib/pg_eventstore/extensions/options_extension.rb +103 -0
  29. data/lib/pg_eventstore/middleware.rb +15 -0
  30. data/lib/pg_eventstore/pg_result_deserializer.rb +48 -0
  31. data/lib/pg_eventstore/queries.rb +127 -0
  32. data/lib/pg_eventstore/query_builders/events_filtering_query.rb +187 -0
  33. data/lib/pg_eventstore/rspec/has_option_matcher.rb +90 -0
  34. data/lib/pg_eventstore/sql_builder.rb +126 -0
  35. data/lib/pg_eventstore/stream.rb +72 -0
  36. data/lib/pg_eventstore/tasks/setup.rake +37 -0
  37. data/lib/pg_eventstore/version.rb +5 -0
  38. data/lib/pg_eventstore.rb +85 -0
  39. data/pg_eventstore.gemspec +40 -0
  40. 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
@@ -0,0 +1,7 @@
1
+ ## [0.1.0] - 2023-12-12
2
+
3
+ Initial release.
4
+
5
+ - Implement Read command
6
+ - Implement AppendToStream command
7
+ - Implement Multiple command
@@ -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
@@ -0,0 +1,2 @@
1
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
2
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
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
+ ```