sourced 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 86885b939ec61102ef829ec6272cbb32e789c91d0be359226e35fd1f70a13aee
4
+ data.tar.gz: 7ee1faaaf4e58800dd31dbb530e4549771e263084ae69379bf72bce01e784b06
5
+ SHA512:
6
+ metadata.gz: 628959098ebde04be796875608fa1410bd3bec7d40a6d362d944eb77769134b4fbc38858caa10cdd5ffa06817d693e5e5c8ca1ed89d4f2cbf25465e57af10ebd
7
+ data.tar.gz: d669516fe37fa9b714d7163b1b2055a1fd3ef91f05b4101c6e469317f6e99842411648017d2f3d0380aef6f9931f2f9cbb57724a47bd638fbb3fe744495ec02f
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --format documentation
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-09-27
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # sourced
2
+
3
+ ** WORK IN PROGRESS**
4
+
5
+ Event Sourcing / CQRS library for Ruby.
6
+ There's many ES gems available already. The objectives here are:
7
+ * Cohesive and toy-like DX.
8
+ * Eventual consistency by default.
9
+ * Explore ES as a programming model for Ruby apps.
10
+
11
+ ## Installation
12
+
13
+ Install the gem and add to the application's Gemfile by executing:
14
+
15
+ $ bundle add sourced
16
+
17
+ **Note**: this gem is under active development, so you probably want to install from Github:
18
+ In your Gemfile:
19
+
20
+ $ gem 'sourced', github: 'ismasan/sourced'
21
+
22
+ ## Usage
23
+
24
+ TODO: Write usage instructions here
25
+
26
+ ## Development
27
+
28
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
29
+
30
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
31
+
32
+ ## Contributing
33
+
34
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/sourced.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/config.ru ADDED
File without changes
data/examples/cart.rb ADDED
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler'
4
+ Bundler.setup(:test)
5
+
6
+ require 'sourced'
7
+ require 'sequel'
8
+
9
+ # ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: 'decider')
10
+ unless ENV['backend_configured']
11
+ puts 'aggregate config'
12
+ Sourced.configure do |config|
13
+ config.backend = Sequel.postgres('sourced_development')
14
+ end
15
+ ENV['backend_configured'] = 'true'
16
+ end
17
+
18
+ # A cart Actor/Aggregate
19
+ # Example:
20
+ # cart = Cart.new('cart-1')
21
+ # cart.add_item(name: 'item1', price: 100)
22
+ # cart.place
23
+ # cart.events
24
+ #
25
+ # The above sends a Cart::Place command
26
+ # which produces a Cart::Placed event
27
+ class Cart < Sourced::Decider
28
+ State = Struct.new(:status, :notified, :items, :mailer_id) do
29
+ def total = items.sum(&:price)
30
+ end
31
+
32
+ def init_state(_id)
33
+ State.new(:open, false, [], nil)
34
+ end
35
+
36
+ ItemAdded = Sourced::Message.define('cart.item_added') do
37
+ attribute :name, String
38
+ attribute :price, Integer
39
+ end
40
+
41
+ Placed = Sourced::Message.define('cart.placed')
42
+ Notified = Sourced::Message.define('cart.notified') do
43
+ attribute :mailer_id, String
44
+ end
45
+
46
+ # Defines a Cart::AddItem command struct
47
+ command :add_item, 'cart.add_item', name: String, price: Integer do |cart, cmd|
48
+ apply(ItemAdded, cmd.payload.to_h)
49
+ end
50
+
51
+ # Defines a Cart::Place command struct
52
+ command :place, 'cart.place' do |_, cmd|
53
+ apply(Placed)
54
+ end
55
+
56
+ # Defines a Cart::Notify command struct
57
+ command :notify, 'cart.notify', mailer_id: String do |_, cmd|
58
+ puts "#{self.class.name} #{cmd.stream_id} NOTIFY"
59
+ apply(Notified, mailer_id: cmd.payload.mailer_id)
60
+ end
61
+
62
+ evolve ItemAdded do |cart, event|
63
+ cart.items << event.payload
64
+ end
65
+
66
+ evolve Placed do |cart, _event|
67
+ cart.status = :placed
68
+ end
69
+
70
+ evolve Notified do |cart, event|
71
+ cart.notified = true
72
+ cart.mailer_id = event.payload.mailer_id
73
+ end
74
+
75
+ # This block will run
76
+ # in the same transaction as appending
77
+ # new events to the store.
78
+ # So if either fails, eveything is rolled back.
79
+ # ergo, strong consistency.
80
+ sync do |command, events|
81
+ puts "#{self.class.name} #{events.last.seq} SYNC"
82
+ end
83
+
84
+ # Or register a Reactor interface to react to events
85
+ # synchronously
86
+ # sync CartListings
87
+ end
88
+
89
+ class Mailer < Sourced::Decider
90
+ EmailSent = Sourced::Message.define('mailer.email_sent') do
91
+ attribute :cart_id, String
92
+ end
93
+
94
+ def init_state(_id)
95
+ []
96
+ end
97
+
98
+ command :send_email, 'mailer.send_email', cart_id: String do |_, cmd|
99
+ # Send email here, emit EmailSent if successful
100
+ apply(EmailSent, cart_id: cmd.payload.cart_id)
101
+ end
102
+
103
+ evolve EmailSent do |list, event|
104
+ list << event
105
+ end
106
+ end
107
+
108
+ # A Saga that orchestrates the flow between Cart and Mailer
109
+ class CartEmailsSaga < Sourced::Decider
110
+ # Listen for Cart::Placed events and
111
+ # send command to Mailer
112
+ react Cart::Placed do |event|
113
+ event.follow_with_stream_id(
114
+ Mailer::SendEmail,
115
+ "mailer-#{event.stream_id}",
116
+ cart_id: event.stream_id
117
+ )
118
+ end
119
+
120
+ # Listen for Mailer::EmailSent events and
121
+ # send command to Cart
122
+ react Mailer::EmailSent do |event|
123
+ event.follow_with_stream_id(
124
+ Cart::Notify,
125
+ event.payload.cart_id,
126
+ mailer_id: event.stream_id
127
+ )
128
+ end
129
+ end
130
+
131
+ # A projector
132
+ # "reacts" to events registered with .evolve
133
+ class CartListings < Sourced::Decider
134
+ class << self
135
+ def handled_events = self.handled_events_for_evolve
136
+
137
+ # The Reactor interface
138
+ # @param events [Array<Message>]
139
+ def handle_events(events)
140
+ # For this type of event sourced projections
141
+ # that load current state from events
142
+ # then apply "new" events
143
+ # TODO: the current state already includes
144
+ # the new events, so we need to load upto events.first.seq
145
+ instance = load(events.first.stream_id, upto: events.first.seq - 1)
146
+ instance.handle_events(events)
147
+ end
148
+ end
149
+
150
+ def handle_events(events)
151
+ evolve(state, events)
152
+ save
153
+ [] # no commands
154
+ end
155
+
156
+ def initialize(id, **_args)
157
+ super
158
+ FileUtils.mkdir_p('examples/carts')
159
+ @path = "./examples/carts/#{id}.json"
160
+ end
161
+
162
+ private def save
163
+ backend.transaction do
164
+ run_sync_blocks(state, nil, [])
165
+ end
166
+ end
167
+
168
+ def init_state(id)
169
+ { id:, items: [], status: :open, seq: 0, seqs: [] }
170
+ end
171
+
172
+ sync do |cart, _command, _events|
173
+ File.write(@path, JSON.pretty_generate(cart))
174
+ end
175
+
176
+ # Register all events from Cart
177
+ # So that before_evolve runs before all cart events
178
+ evolve_all Cart.handled_commands
179
+ evolve_all Cart
180
+
181
+ before_evolve do |cart, event|
182
+ cart[:seq] = event.seq
183
+ cart[:seqs] << event.seq
184
+ end
185
+
186
+ evolve Cart::Placed do |cart, event|
187
+ cart[:status] = :placed
188
+ end
189
+
190
+ evolve Cart::ItemAdded do |cart, event|
191
+ cart[:items] << event.payload.to_h
192
+ end
193
+ end
194
+
195
+ class LoggingReactor
196
+ extend Sourced::Consumer
197
+
198
+ class << self
199
+ # Register as a Reactor that cares about these events
200
+ # The workers will use this to fetch the right events
201
+ # and ACK offsets after processing
202
+ #
203
+ # @return [Array<Message>]
204
+ def handled_events = [Cart::Placed, Cart::ItemAdded]
205
+
206
+ # Workers pass available events to this method
207
+ # in order, with exactly-once semantics
208
+ # If a list of commands is returned,
209
+ # workers will send them to the router
210
+ # to be dispatched to the appropriate command handlers.
211
+ #
212
+ # @param events [Array<Message>]
213
+ # @return [Array<Message]
214
+ def handle_events(events)
215
+ puts "LoggingReactor received #{events}"
216
+ []
217
+ end
218
+ end
219
+ end
220
+
221
+ # Cart.sync CartListings
222
+
223
+ # Register Reactor interfaces with the Router
224
+ # This allows the Router to route commands and events to reactors
225
+ Sourced::Router.register(LoggingReactor)
226
+ Sourced::Router.register(Cart)
227
+ Sourced::Router.register(Mailer)
228
+ Sourced::Router.register(CartEmailsSaga)
229
+ Sourced::Router.register(CartListings)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './cart'
4
+
5
+ Sourced::Supervisor.start
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'json'
5
+ require 'sourced/message'
6
+
7
+ module Sourced
8
+ module Backends
9
+ class ActiveRecordBackend
10
+ PREFIX = 'sourced'
11
+
12
+ class EventRecord < ActiveRecord::Base
13
+ self.inheritance_column = nil
14
+ self.table_name = [PREFIX, '_events'].join
15
+ end
16
+
17
+ class StreamRecord < ActiveRecord::Base
18
+ self.table_name = [PREFIX, '_streams'].join
19
+ end
20
+
21
+ class CommandRecord < ActiveRecord::Base
22
+ self.table_name = [PREFIX, '_commands'].join
23
+ end
24
+
25
+ def self.table_prefix=(prefix)
26
+ @table_prefix = prefix
27
+ EventRecord.table_name = [prefix, '_events'].join
28
+ StreamRecord.table_name = [prefix, '_streams'].join
29
+ CommandRecord.table_name = [prefix, '_commands'].join
30
+ end
31
+
32
+ def self.table_prefix
33
+ @table_prefix || PREFIX
34
+ end
35
+
36
+ def self.installed?
37
+ ActiveRecord::Base.connection.table_exists?(EventRecord.table_name) &&
38
+ ActiveRecord::Base.connection.table_exists?(StreamRecord.table_name) &&
39
+ ActiveRecord::Base.connection.table_exists?(CommandRecord.table_name)
40
+ end
41
+
42
+ def self.uninstall!
43
+ raise 'Not in test environment' unless ENV['ENVIRONMENT'] == 'test'
44
+
45
+ ActiveRecord::Base.connection.drop_table(EventRecord.table_name)
46
+ ActiveRecord::Base.connection.drop_table(CommandRecord.table_name)
47
+ ActiveRecord::Base.connection.drop_table(StreamRecord.table_name)
48
+ end
49
+
50
+ def initialize
51
+ @serialize_event = method(:serialize_jsonb_event)
52
+ @deserialize_event = method(:deserialize_jsonb_event)
53
+ if EventRecord.connection.class.name == 'ActiveRecord::ConnectionAdapters::SQLite3Adapter'
54
+ @serialize_event = method(:serialize_sqlite_event)
55
+ @deserialize_event = method(:deserialize_sqlite_event)
56
+ end
57
+ end
58
+
59
+ def installed? = self.class.installed?
60
+
61
+ def clear!
62
+ raise 'Not in test environment' unless ENV['ENVIRONMENT'] == 'test'
63
+
64
+ EventRecord.delete_all
65
+ CommandRecord.delete_all
66
+ StreamRecord.delete_all
67
+ end
68
+
69
+ # TODO: if all commands belong to the same stream_id
70
+ # we could upsert the streams table once here
71
+ def schedule_commands(commands)
72
+ return false if commands.empty?
73
+
74
+ # TODO: here we could use multi_insert
75
+ # for both streams and commands
76
+ CommandRecord.transaction do
77
+ commands.each do |command|
78
+ schedule_command(command.stream_id, command)
79
+ end
80
+ end
81
+ true
82
+ end
83
+
84
+ def schedule_command(stream_id, command)
85
+ CommandRecord.transaction do
86
+ StreamRecord.upsert({ stream_id: }, unique_by: :stream_id)
87
+ CommandRecord.create!(stream_id:, data: command.to_json)
88
+ end
89
+ end
90
+
91
+ # TODO: locking the stream could be
92
+ # done in a single SQL query, or using an SQL function.
93
+ def reserve_next(&)
94
+ command_record = transaction do
95
+ cmd = CommandRecord
96
+ .joins("INNER JOIN #{StreamRecord.table_name} ON #{CommandRecord.table_name}.stream_id = #{StreamRecord.table_name}.stream_id")
97
+ .where(["#{StreamRecord.table_name}.locked = ?", false])
98
+ .order("#{CommandRecord.table_name}.id ASC")
99
+ .lock # "FOR UPDATE"
100
+ .first
101
+
102
+ if cmd
103
+ StreamRecord.where(stream_id: cmd.stream_id).update(locked: true)
104
+ end
105
+ cmd
106
+ end
107
+
108
+ cmd = nil
109
+ if command_record
110
+ # TODO: find out why #data isn't already
111
+ # deserialized here
112
+ data = JSON.parse(command_record.data, symbolize_names: true)
113
+ cmd = Message.from(data)
114
+ yield cmd
115
+ # Only delete the command if processing didn't raise
116
+ command_record.destroy
117
+ end
118
+ cmd
119
+ ensure
120
+ # Always unlock the stream
121
+ if command_record
122
+ StreamRecord.where(stream_id: command_record.stream_id).update(locked: false)
123
+ end
124
+ end
125
+
126
+ def transaction(&)
127
+ EventRecord.transaction(&)
128
+ end
129
+
130
+ def append_events(events)
131
+ rows = events.map { |e| serialize_event(e) }
132
+ EventRecord.insert_all!(rows)
133
+ true
134
+ rescue ActiveRecord::RecordNotUnique => e
135
+ raise Sourced::ConcurrentAppendError, e.message
136
+ end
137
+
138
+ def read_event_batch(causation_id)
139
+ EventRecord.where(causation_id:).order(:global_seq).map do |record|
140
+ deserialize_event(record)
141
+ end
142
+ end
143
+
144
+ def read_event_stream(stream_id, after: nil, upto: nil)
145
+ query = EventRecord.where(stream_id:)
146
+ query = query.where('seq > ?', after) if after
147
+ query = query.where('seq <= ?', upto) if upto
148
+ query.order(:global_seq).map do |record|
149
+ deserialize_event(record)
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def serialize_event(event)
156
+ @serialize_event.(event)
157
+ end
158
+
159
+ def serialize_jsonb_event(event)
160
+ event.to_h
161
+ end
162
+
163
+ def serialize_sqlite_event(event)
164
+ attrs = event.to_h
165
+ attrs[:payload] = JSON.generate(attrs[:payload]) if attrs[:payload].present?
166
+ attrs
167
+ end
168
+
169
+ def deserialize_event(record)
170
+ Message.from(@deserialize_event.(record).deep_symbolize_keys)
171
+ end
172
+
173
+ def deserialize_jsonb_event(record)
174
+ record.attributes
175
+ end
176
+
177
+ def deserialize_sqlite_event(record)
178
+ attrs = record.attributes
179
+ attrs['payload'] = JSON.parse(attrs['payload']) if attrs['payload'].present?
180
+ attrs
181
+ end
182
+ end
183
+ end
184
+ end