acta 0.2.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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.tool-versions +1 -0
  3. data/CHANGELOG.md +210 -0
  4. data/LICENSE +21 -0
  5. data/PLAN.md +158 -0
  6. data/README.md +559 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/acta/web/application_controller.rb +10 -0
  9. data/app/controllers/acta/web/events_controller.rb +37 -0
  10. data/app/helpers/acta/web/application_helper.rb +106 -0
  11. data/app/views/acta/web/events/index.html.erb +312 -0
  12. data/app/views/acta/web/events/show.html.erb +72 -0
  13. data/app/views/layouts/acta/web/application.html.erb +594 -0
  14. data/config/routes.rb +4 -0
  15. data/lib/acta/actor.rb +34 -0
  16. data/lib/acta/adapters/base.rb +59 -0
  17. data/lib/acta/adapters/postgres.rb +73 -0
  18. data/lib/acta/adapters/sqlite.rb +58 -0
  19. data/lib/acta/adapters.rb +19 -0
  20. data/lib/acta/array_type.rb +30 -0
  21. data/lib/acta/command.rb +48 -0
  22. data/lib/acta/current.rb +10 -0
  23. data/lib/acta/errors.rb +102 -0
  24. data/lib/acta/event.rb +80 -0
  25. data/lib/acta/events_query.rb +73 -0
  26. data/lib/acta/handler.rb +9 -0
  27. data/lib/acta/model.rb +58 -0
  28. data/lib/acta/model_type.rb +32 -0
  29. data/lib/acta/projection.rb +64 -0
  30. data/lib/acta/projection_managed.rb +108 -0
  31. data/lib/acta/railtie.rb +65 -0
  32. data/lib/acta/reactor.rb +15 -0
  33. data/lib/acta/reactor_job.rb +19 -0
  34. data/lib/acta/record.rb +10 -0
  35. data/lib/acta/schema.rb +12 -0
  36. data/lib/acta/serializable.rb +48 -0
  37. data/lib/acta/testing/dsl.rb +90 -0
  38. data/lib/acta/testing/matchers.rb +77 -0
  39. data/lib/acta/testing.rb +50 -0
  40. data/lib/acta/types/encrypted_string.rb +63 -0
  41. data/lib/acta/version.rb +5 -0
  42. data/lib/acta/web/engine.rb +13 -0
  43. data/lib/acta/web/events_query.rb +81 -0
  44. data/lib/acta/web.rb +45 -0
  45. data/lib/acta.rb +296 -0
  46. data/lib/generators/acta/install/install_generator.rb +23 -0
  47. data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
  48. data/sig/acta.rbs +4 -0
  49. metadata +152 -0
data/lib/acta/web.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ module Web
5
+ # Raised when the engine is mounted without `base_controller_class`
6
+ # being set. Without a configured parent, the engine's controllers
7
+ # would inherit ActionController::Base directly — meaning the event
8
+ # log is publicly accessible. Fail loudly at request time so the
9
+ # mistake surfaces in development before reaching production.
10
+ class ConfigurationError < StandardError; end
11
+
12
+ class << self
13
+ # The host-app controller class (as a String) that engine controllers
14
+ # should inherit from. Set this to your `ApplicationController` (or any
15
+ # base controller that enforces authentication) before mounting:
16
+ #
17
+ # # config/initializers/acta_web.rb
18
+ # Acta::Web.base_controller_class = "ApplicationController"
19
+ #
20
+ # No default is provided: a misconfigured mount would expose the
21
+ # entire event log without authentication.
22
+ def base_controller_class
23
+ @base_controller_class || raise(
24
+ ConfigurationError,
25
+ "Acta::Web.base_controller_class is not set. Configure it before " \
26
+ "mounting the engine, e.g. in config/initializers/acta_web.rb:\n\n" \
27
+ " Acta::Web.base_controller_class = \"ApplicationController\"\n\n" \
28
+ "Set it to a controller class that enforces authentication so the " \
29
+ "event log isn't publicly accessible."
30
+ )
31
+ end
32
+
33
+ def base_controller_class=(klass)
34
+ @base_controller_class = klass
35
+ end
36
+
37
+ # Test/reset hook — clears the configured controller class. Mainly for specs.
38
+ def reset_configuration!
39
+ @base_controller_class = nil
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ require_relative "web/engine"
data/lib/acta.rb ADDED
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "acta/version"
4
+ require_relative "acta/errors"
5
+ require_relative "acta/actor"
6
+ require_relative "acta/current"
7
+ require_relative "acta/types/encrypted_string"
8
+ require_relative "acta/model"
9
+ require_relative "acta/serializable"
10
+ require_relative "acta/event"
11
+ require_relative "acta/schema"
12
+ require_relative "acta/record"
13
+ require_relative "acta/adapters"
14
+ require_relative "acta/events_query"
15
+ require_relative "acta/handler"
16
+ require_relative "acta/projection"
17
+ require_relative "acta/reactor"
18
+ require_relative "acta/reactor_job"
19
+ require_relative "acta/command"
20
+ require_relative "acta/projection_managed"
21
+ require_relative "acta/railtie" if defined?(::Rails::Railtie)
22
+
23
+ require "active_support/lazy_load_hooks"
24
+ ActiveSupport.on_load(:active_record) do
25
+ include Acta::ProjectionManaged
26
+ end
27
+
28
+ module Acta
29
+ def self.adapter
30
+ @adapter ||= Adapters.for(Record.connection)
31
+ end
32
+
33
+ def self.reset_adapter!
34
+ @adapter = nil
35
+ end
36
+
37
+ def self.emit(event, actor: nil, if_version: nil)
38
+ event.actor = actor if actor
39
+ raise MissingActor, "No actor for emit of #{event.event_type} (set Acta::Current.actor or pass actor:)" if event.actor.nil?
40
+
41
+ assert_version!(event, if_version) unless if_version.nil?
42
+
43
+ ActiveSupport::Notifications.instrument("acta.event_emitted", event:, event_type: event.event_type) do
44
+ Record.transaction(requires_new: true) do
45
+ record = adapter.insert_event(record_attributes_for(event))
46
+ event.recorded_at = record.recorded_at
47
+ dispatch(event, kind: :projection)
48
+ end
49
+ dispatch(event, kind: :handler)
50
+ dispatch(event, kind: :reactor)
51
+ end
52
+
53
+ event
54
+ end
55
+
56
+ def self.subscribe(event_class, handler_class, &block)
57
+ handlers[event_class] << { handler_class:, block:, kind: handler_kind(handler_class) }
58
+ end
59
+
60
+ def self.handlers
61
+ @handlers ||= Hash.new { |h, k| h[k] = [] }
62
+ end
63
+
64
+ def self.dispatch(event, kind: nil)
65
+ handlers.each do |event_class, registrations|
66
+ next unless event.is_a?(event_class)
67
+
68
+ registrations.each do |registration|
69
+ next if kind && registration[:kind] != kind
70
+
71
+ invoke(event, registration)
72
+ end
73
+ end
74
+ end
75
+
76
+ def self.invoke(event, registration)
77
+ case registration[:kind]
78
+ when :projection then run_projection(event, registration)
79
+ when :reactor then run_reactor(event, registration)
80
+ else registration[:block].call(event)
81
+ end
82
+ end
83
+ private_class_method :invoke
84
+
85
+ def self.run_projection(event, registration)
86
+ ActiveSupport::Notifications.instrument(
87
+ "acta.projection_applied",
88
+ event:,
89
+ projection_class: registration[:handler_class]
90
+ ) do
91
+ Projection.applying! { registration[:block].call(event) }
92
+ end
93
+ rescue ProjectionError
94
+ raise
95
+ rescue StandardError => e
96
+ raise ProjectionError.new(
97
+ event:,
98
+ projection_class: registration[:handler_class],
99
+ original: e
100
+ )
101
+ end
102
+ private_class_method :run_projection
103
+
104
+ def self.run_reactor(event, registration)
105
+ if registration[:handler_class].sync?
106
+ ActiveSupport::Notifications.instrument(
107
+ "acta.reactor_invoked",
108
+ event:,
109
+ reactor_class: registration[:handler_class],
110
+ sync: true
111
+ ) do
112
+ registration[:block].call(event)
113
+ end
114
+ else
115
+ ActiveSupport::Notifications.instrument(
116
+ "acta.reactor_enqueued",
117
+ event:,
118
+ reactor_class: registration[:handler_class]
119
+ ) do
120
+ ReactorJob.perform_later(
121
+ event_uuid: event.uuid,
122
+ reactor_class: registration[:handler_class].name,
123
+ event_class: event.class.name
124
+ )
125
+ end
126
+ end
127
+ end
128
+ private_class_method :run_reactor
129
+
130
+ def self.reset_handlers!
131
+ @handlers = Hash.new { |h, k| h[k] = [] }
132
+ @projection_classes = []
133
+ end
134
+
135
+ def self.projection_classes
136
+ @projection_classes ||= []
137
+ end
138
+
139
+ def self.register_projection(klass)
140
+ projection_classes << klass unless projection_classes.include?(klass)
141
+ end
142
+
143
+ def self.rebuild!
144
+ Projection.applying! { truncate_projections! }
145
+ Record.order(:id).find_each do |record|
146
+ event = events.find_by_uuid(record.uuid)
147
+ dispatch(event, kind: :projection)
148
+ rescue ProjectionError
149
+ raise
150
+ rescue StandardError => e
151
+ raise ReplayError.new(record:, original: e)
152
+ end
153
+ end
154
+
155
+ # Truncate all projections in FK-safe order. Wrapped in `Projection.applying!`
156
+ # by `rebuild!` so projection-managed AR models (`acta_managed!`) accept the
157
+ # delete_all calls instead of raising `ProjectionWriteError`.
158
+ def self.truncate_projections!
159
+ legacy, declared = projection_classes.partition { |p| p.truncated_classes.empty? }
160
+
161
+ legacy.each(&:truncate!)
162
+ truncate_order(declared).each(&:truncate!)
163
+ end
164
+ private_class_method :truncate_projections!
165
+
166
+ # Order projections so that, for every belongs_to A → B where A and B are
167
+ # owned by different projections, the projection owning A truncates first.
168
+ # This deletes children before parents and keeps Acta.rebuild! safe under
169
+ # FK constraints regardless of registration order.
170
+ def self.truncate_order(projections)
171
+ return projections if projections.length < 2
172
+
173
+ owner_of = projections.each_with_object({}) do |projection, acc|
174
+ projection.truncated_classes.each { |klass| acc[klass] = projection }
175
+ end
176
+
177
+ # `before[parent] = [children]`: every child projection must run before
178
+ # the parent so the FK-bearing rows are gone by the time the parent
179
+ # tries to delete the rows they reference.
180
+ before = Hash.new { |h, k| h[k] = [] }
181
+ projections.each do |child_projection|
182
+ child_projection.truncated_classes.each do |child_class|
183
+ child_class.reflect_on_all_associations(:belongs_to).each do |reflection|
184
+ next if reflection.polymorphic?
185
+
186
+ parent_class = begin
187
+ reflection.klass
188
+ rescue StandardError
189
+ next
190
+ end
191
+
192
+ parent_owner = owner_of[parent_class]
193
+ next if parent_owner.nil? || parent_owner == child_projection
194
+
195
+ before[parent_owner] << child_projection unless before[parent_owner].include?(child_projection)
196
+ end
197
+ end
198
+ end
199
+
200
+ sorted = topological_sort(projections, before)
201
+ sorted || raise(TruncateOrderError.new(projections))
202
+ end
203
+ private_class_method :truncate_order
204
+
205
+ # Given `before[node] = [predecessors]`, returns nodes ordered so each
206
+ # predecessor appears before the node it constrains, or nil if the graph
207
+ # has a cycle. Stable: preserves input order among nodes that don't
208
+ # constrain each other.
209
+ def self.topological_sort(nodes, before)
210
+ visited = {}
211
+ result = []
212
+
213
+ visit = lambda do |node|
214
+ case visited[node]
215
+ when :done then return true
216
+ when :visiting then return false
217
+ end
218
+
219
+ visited[node] = :visiting
220
+ before[node].each { |predecessor| return false unless visit.call(predecessor) }
221
+ visited[node] = :done
222
+ result << node
223
+ true
224
+ end
225
+
226
+ nodes.each { |node| return nil unless visit.call(node) }
227
+
228
+ result
229
+ end
230
+ private_class_method :topological_sort
231
+
232
+ def self.handler_kind(handler_class)
233
+ if handler_class <= Projection
234
+ :projection
235
+ elsif handler_class <= Reactor
236
+ :reactor
237
+ else
238
+ :handler
239
+ end
240
+ end
241
+ private_class_method :handler_kind
242
+
243
+ # Public: read the current high-water mark for a stream. Returns 0 for
244
+ # streams that have never been emitted to. Use the result with
245
+ # `Acta.emit(..., if_version: version)` for optimistic locking.
246
+ def self.version_of(stream_type:, stream_key:)
247
+ Record
248
+ .where(stream_type: stream_type.to_s, stream_key: stream_key)
249
+ .maximum(:stream_sequence) || 0
250
+ end
251
+
252
+ def self.assert_version!(event, expected)
253
+ if event.stream_type.nil? || event.stream_key.nil?
254
+ raise ArgumentError, "if_version requires the event to declare a stream"
255
+ end
256
+
257
+ actual = version_of(stream_type: event.stream_type, stream_key: event.stream_key)
258
+ return if actual == expected
259
+
260
+ raise VersionConflict.new(
261
+ stream_type: event.stream_type,
262
+ stream_key: event.stream_key,
263
+ expected_version: expected,
264
+ actual_version: actual
265
+ )
266
+ end
267
+ private_class_method :assert_version!
268
+
269
+ def self.events
270
+ EventsQuery.new(adapter.fetch_records)
271
+ end
272
+
273
+ def self.record_attributes_for(event)
274
+ actor = event.actor
275
+ {
276
+ uuid: event.uuid,
277
+ event_type: event.event_type,
278
+ event_version: event.event_version,
279
+ stream_type: event.stream_type,
280
+ stream_key: event.stream_key,
281
+ payload: event.payload_hash,
282
+ actor_type: actor.type,
283
+ actor_id: actor.id,
284
+ source: actor.source,
285
+ metadata: (actor.metadata.empty? ? nil : actor.metadata),
286
+ occurred_at: event.occurred_at,
287
+ recorded_at: Time.current
288
+ }
289
+ end
290
+ private_class_method :record_attributes_for
291
+ end
292
+
293
+ # The web admin engine is opt-in: required only when the host runs Rails.
294
+ # Loading it unconditionally would pull in ActionController etc. for
295
+ # non-Rails consumers (background jobs, scripts).
296
+ require_relative "acta/web" if defined?(::Rails)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "rails/generators/active_record"
6
+
7
+ module Acta
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ def self.next_migration_number(path)
15
+ ActiveRecord::Generators::Base.next_migration_number(path)
16
+ end
17
+
18
+ def create_migration_file
19
+ migration_template "create_acta_events.rb.tt", "db/migrate/create_acta_events.rb"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ class CreateActaEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def up
3
+ Acta::Schema.install(connection)
4
+ end
5
+
6
+ def down
7
+ drop_table :events
8
+ end
9
+ end
data/sig/acta.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Acta
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,152 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acta
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Gladhill
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activemodel
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activerecord
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '8.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '8.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activesupport
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '8.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '8.1'
68
+ description: |-
69
+ Acta ships a small, opinionated set of primitives for event-driven and
70
+ event-sourced Rails applications: events, handlers, projections, reactors,
71
+ and commands. Projections run synchronously inside the emit transaction;
72
+ reactors fan out via ActiveJob. ActiveModel-backed payloads with support
73
+ for nested models and ActiveRecord piggyback. SQLite and Postgres adapters.
74
+ email:
75
+ - tom@gladhill.ca
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - ".tool-versions"
81
+ - CHANGELOG.md
82
+ - LICENSE
83
+ - PLAN.md
84
+ - README.md
85
+ - Rakefile
86
+ - app/controllers/acta/web/application_controller.rb
87
+ - app/controllers/acta/web/events_controller.rb
88
+ - app/helpers/acta/web/application_helper.rb
89
+ - app/views/acta/web/events/index.html.erb
90
+ - app/views/acta/web/events/show.html.erb
91
+ - app/views/layouts/acta/web/application.html.erb
92
+ - config/routes.rb
93
+ - lib/acta.rb
94
+ - lib/acta/actor.rb
95
+ - lib/acta/adapters.rb
96
+ - lib/acta/adapters/base.rb
97
+ - lib/acta/adapters/postgres.rb
98
+ - lib/acta/adapters/sqlite.rb
99
+ - lib/acta/array_type.rb
100
+ - lib/acta/command.rb
101
+ - lib/acta/current.rb
102
+ - lib/acta/errors.rb
103
+ - lib/acta/event.rb
104
+ - lib/acta/events_query.rb
105
+ - lib/acta/handler.rb
106
+ - lib/acta/model.rb
107
+ - lib/acta/model_type.rb
108
+ - lib/acta/projection.rb
109
+ - lib/acta/projection_managed.rb
110
+ - lib/acta/railtie.rb
111
+ - lib/acta/reactor.rb
112
+ - lib/acta/reactor_job.rb
113
+ - lib/acta/record.rb
114
+ - lib/acta/schema.rb
115
+ - lib/acta/serializable.rb
116
+ - lib/acta/testing.rb
117
+ - lib/acta/testing/dsl.rb
118
+ - lib/acta/testing/matchers.rb
119
+ - lib/acta/types/encrypted_string.rb
120
+ - lib/acta/version.rb
121
+ - lib/acta/web.rb
122
+ - lib/acta/web/engine.rb
123
+ - lib/acta/web/events_query.rb
124
+ - lib/generators/acta/install/install_generator.rb
125
+ - lib/generators/acta/install/templates/create_acta_events.rb.tt
126
+ - sig/acta.rbs
127
+ homepage: https://github.com/whoojemaflip/acta
128
+ licenses:
129
+ - MIT
130
+ metadata:
131
+ homepage_uri: https://github.com/whoojemaflip/acta
132
+ source_code_uri: https://github.com/whoojemaflip/acta
133
+ changelog_uri: https://github.com/whoojemaflip/acta/blob/main/CHANGELOG.md
134
+ bug_tracker_uri: https://github.com/whoojemaflip/acta/issues
135
+ rdoc_options: []
136
+ require_paths:
137
+ - lib
138
+ required_ruby_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '3.4'
143
+ required_rubygems_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '0'
148
+ requirements: []
149
+ rubygems_version: 3.7.2
150
+ specification_version: 4
151
+ summary: Lightweight event-driven and event-sourced primitives for Rails.
152
+ test_files: []