acta 0.2.0 → 0.4.0.alpha.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.
data/lib/acta.rb CHANGED
@@ -17,6 +17,7 @@ require_relative "acta/projection"
17
17
  require_relative "acta/reactor"
18
18
  require_relative "acta/reactor_job"
19
19
  require_relative "acta/command"
20
+ require_relative "acta/upcaster"
20
21
  require_relative "acta/projection_managed"
21
22
  require_relative "acta/railtie" if defined?(::Rails::Railtie)
22
23
 
@@ -26,6 +27,18 @@ ActiveSupport.on_load(:active_record) do
26
27
  end
27
28
 
28
29
  module Acta
30
+ # Global default for `Acta::Reactor` ActiveJob queue. Per-class
31
+ # `queue_as :foo` declarations on a Reactor override this. Apps with
32
+ # queue priority discipline typically set this to e.g. `:fast` in an
33
+ # initializer; left nil, ActiveJob's `:default` queue is used.
34
+ class << self
35
+ attr_writer :reactor_queue
36
+ end
37
+
38
+ def self.reactor_queue
39
+ @reactor_queue
40
+ end
41
+
29
42
  def self.adapter
30
43
  @adapter ||= Adapters.for(Record.connection)
31
44
  end
@@ -117,7 +130,10 @@ module Acta
117
130
  event:,
118
131
  reactor_class: registration[:handler_class]
119
132
  ) do
120
- ReactorJob.perform_later(
133
+ job = ReactorJob
134
+ queue = registration[:handler_class].queue_name
135
+ job = job.set(queue: queue) if queue
136
+ job.perform_later(
121
137
  event_uuid: event.uuid,
122
138
  reactor_class: registration[:handler_class].name,
123
139
  event_class: event.class.name
@@ -140,12 +156,29 @@ module Acta
140
156
  projection_classes << klass unless projection_classes.include?(klass)
141
157
  end
142
158
 
159
+ # Register a set of upcasters (a module/class that `include Acta::Upcaster`
160
+ # and declares `upcasts(...)` blocks). Idempotent — re-registering the
161
+ # same class is a no-op. See `Acta::Upcaster`.
162
+ def self.register_upcaster(klass)
163
+ upcaster_registry.register(klass)
164
+ end
165
+
166
+ def self.upcaster_registry
167
+ @upcaster_registry ||= Upcaster::Registry.new
168
+ end
169
+
170
+ def self.reset_upcasters!
171
+ upcaster_registry.clear!
172
+ end
173
+
143
174
  def self.rebuild!
144
175
  Projection.applying! { truncate_projections! }
176
+ context = Upcaster::Context.new
145
177
  Record.order(:id).find_each do |record|
146
- event = events.find_by_uuid(record.uuid)
147
- dispatch(event, kind: :projection)
148
- rescue ProjectionError
178
+ events.upcast_and_hydrate(record, context).each do |event|
179
+ dispatch(event, kind: :projection)
180
+ end
181
+ rescue ProjectionError, ReplayHaltedByUpcaster, FutureSchemaVersion
149
182
  raise
150
183
  rescue StandardError => e
151
184
  raise ReplayError.new(record:, original: e)
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
- require "rails/generators/migration"
5
4
  require "rails/generators/active_record"
5
+ require "rails/generators/active_record/migration"
6
6
 
7
7
  module Acta
8
8
  module Generators
9
9
  class InstallGenerator < Rails::Generators::Base
10
- include Rails::Generators::Migration
10
+ include ActiveRecord::Generators::Migration
11
11
 
12
12
  source_root File.expand_path("templates", __dir__)
13
13
 
14
- def self.next_migration_number(path)
15
- ActiveRecord::Generators::Base.next_migration_number(path)
16
- end
14
+ class_option :database, type: :string, aliases: %i[--db],
15
+ desc: "The database for the events migration. By default, the current environment's primary database is used."
17
16
 
18
17
  def create_migration_file
19
- migration_template "create_acta_events.rb.tt", "db/migrate/create_acta_events.rb"
18
+ migration_template "create_acta_events.rb.tt",
19
+ File.join(db_migrate_path, "create_acta_events.rb")
20
20
  end
21
21
  end
22
22
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acta
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0.alpha.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Gladhill
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-05-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activejob
@@ -15,56 +15,56 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.1'
18
+ version: '7.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.1'
25
+ version: '7.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activemodel
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '8.1'
32
+ version: '7.2'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '8.1'
39
+ version: '7.2'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: activerecord
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '8.1'
46
+ version: '7.2'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '8.1'
53
+ version: '7.2'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: activesupport
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '8.1'
60
+ version: '7.2'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '8.1'
67
+ version: '7.2'
68
68
  description: |-
69
69
  Acta ships a small, opinionated set of primitives for event-driven and
70
70
  event-sourced Rails applications: events, handlers, projections, reactors,
@@ -80,8 +80,8 @@ files:
80
80
  - ".tool-versions"
81
81
  - CHANGELOG.md
82
82
  - LICENSE
83
- - PLAN.md
84
83
  - README.md
84
+ - RELEASING.md
85
85
  - Rakefile
86
86
  - app/controllers/acta/web/application_controller.rb
87
87
  - app/controllers/acta/web/events_controller.rb
@@ -90,13 +90,18 @@ files:
90
90
  - app/views/acta/web/events/show.html.erb
91
91
  - app/views/layouts/acta/web/application.html.erb
92
92
  - config/routes.rb
93
+ - docs/README.md
94
+ - docs/event_driven_pub_sub.md
95
+ - docs/upcasters.md
96
+ - gemfiles/rails_7_2.gemfile
97
+ - gemfiles/rails_8_0.gemfile
98
+ - gemfiles/rails_8_1.gemfile
93
99
  - lib/acta.rb
94
100
  - lib/acta/actor.rb
95
101
  - lib/acta/adapters.rb
96
102
  - lib/acta/adapters/base.rb
97
103
  - lib/acta/adapters/postgres.rb
98
104
  - lib/acta/adapters/sqlite.rb
99
- - lib/acta/array_type.rb
100
105
  - lib/acta/command.rb
101
106
  - lib/acta/current.rb
102
107
  - lib/acta/errors.rb
@@ -104,7 +109,6 @@ files:
104
109
  - lib/acta/events_query.rb
105
110
  - lib/acta/handler.rb
106
111
  - lib/acta/model.rb
107
- - lib/acta/model_type.rb
108
112
  - lib/acta/projection.rb
109
113
  - lib/acta/projection_managed.rb
110
114
  - lib/acta/railtie.rb
@@ -116,7 +120,10 @@ files:
116
120
  - lib/acta/testing.rb
117
121
  - lib/acta/testing/dsl.rb
118
122
  - lib/acta/testing/matchers.rb
123
+ - lib/acta/types/array.rb
119
124
  - lib/acta/types/encrypted_string.rb
125
+ - lib/acta/types/model.rb
126
+ - lib/acta/upcaster.rb
120
127
  - lib/acta/version.rb
121
128
  - lib/acta/web.rb
122
129
  - lib/acta/web/engine.rb
@@ -128,10 +135,10 @@ homepage: https://github.com/whoojemaflip/acta
128
135
  licenses:
129
136
  - MIT
130
137
  metadata:
131
- homepage_uri: https://github.com/whoojemaflip/acta
132
138
  source_code_uri: https://github.com/whoojemaflip/acta
133
139
  changelog_uri: https://github.com/whoojemaflip/acta/blob/main/CHANGELOG.md
134
140
  bug_tracker_uri: https://github.com/whoojemaflip/acta/issues
141
+ rubygems_mfa_required: 'true'
135
142
  rdoc_options: []
136
143
  require_paths:
137
144
  - lib
@@ -139,14 +146,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
139
146
  requirements:
140
147
  - - ">="
141
148
  - !ruby/object:Gem::Version
142
- version: '3.4'
149
+ version: '3.2'
143
150
  required_rubygems_version: !ruby/object:Gem::Requirement
144
151
  requirements:
145
152
  - - ">="
146
153
  - !ruby/object:Gem::Version
147
154
  version: '0'
148
155
  requirements: []
149
- rubygems_version: 3.7.2
156
+ rubygems_version: 3.6.2
150
157
  specification_version: 4
151
158
  summary: Lightweight event-driven and event-sourced primitives for Rails.
152
159
  test_files: []
data/PLAN.md DELETED
@@ -1,158 +0,0 @@
1
- # Acta Implementation Plan
2
-
3
- Companion to the design doc (private, at `~/Sites/Journal/ideas/event_source_rails.md`).
4
- This file is version-controlled alongside the code and tracks the milestone
5
- breakdown for reaching v1.0.
6
-
7
- ## Conventions
8
-
9
- - **Ruby hash shorthand** (Ruby 3.1+): `{ name:, age: }` when variables in
10
- scope match keys. Applied everywhere.
11
- - **RSpec** exclusively for v1. Minitest matcher support considered post-v1.
12
- - **TDD**: every milestone is a sequence of small red → green → refactor
13
- cycles. One behaviour per commit where practical.
14
- - **Rubocop**: `rubocop-rails-omakase` style. Clean before each commit.
15
- - **Commits**: atomic, imperative mood, one logical change each.
16
- - **Semver** from v0.1. Public API stability begins at v1.0.
17
-
18
- ## Environment
19
-
20
- - Ruby: 3.4+
21
- - Rails floor: 8.1+
22
- - Local: `~/Sites/acta`
23
- - Remote: `git@github.com:whoojemaflip/acta.git`
24
-
25
- ## Milestone breakdown
26
-
27
- Each milestone is independently shippable.
28
-
29
- ### M0 — Scaffolding ✅
30
-
31
- Gem skeleton, RSpec, rubocop-rails-omakase, CI, README, LICENSE, CHANGELOG,
32
- `PLAN.md` in repo. Baseline green build.
33
-
34
- ### M1 — First emit (the round-trip milestone) ✅
35
-
36
- **Goal:** `Acta.emit(event)` persists a row; `Acta.events.last` reads it back.
37
-
38
- 1. Adapter seam — spec `Acta::Adapters::Base` interface; SQLite adapter stub.
39
- 2. Migration generator — `rails g acta:install` creates the events table.
40
- 3. `Acta::Model` — AM::Attributes + AM::Model + `to_acta_hash` / `from_acta_hash`
41
- + `validate!` in initialize raising `Acta::InvalidEvent`.
42
- 4. `Acta::Event < Acta::Model` — adds `uuid`, `event_type`, `event_version`,
43
- `occurred_at`, `recorded_at`, `actor`.
44
- 5. `Acta::Actor` value object — `type`, `id`, `source`, `metadata`.
45
- 6. `Acta::Current` — CurrentAttributes with `actor`.
46
- 7. `Acta.configure` — connection + single-store `:default` registration
47
- (latent store concept).
48
- 8. `Acta.emit(event)` — strict on missing actor (`Acta::MissingActor`);
49
- persists via adapter; returns the persisted event.
50
- 9. `Acta.events` — query API returning `Acta::Event` instances from the log.
51
- 10. Error leaves so far: `Error`, `InvalidEvent`, `MissingActor`,
52
- `ConfigurationError`, `AdapterError`.
53
-
54
- **Checkpoint:** end-to-end spec that configures, emits, queries, asserts.
55
-
56
- ### M2 — Streams & concurrency ✅
57
-
58
- 1. Stream DSL — `stream :order, key: :order_id` on event classes.
59
- 2. Sequence calculation in SQLite adapter (BEGIN IMMEDIATE + SELECT MAX).
60
- 3. `ConcurrencyConflict` on unique-index violation.
61
- 4. Stream-scoped query — `Acta.events.for_stream(type:, key:)`.
62
- 5. `on_concurrent_write :raise` machinery (wires into M6 commands).
63
-
64
- ### M3 — Handlers & dispatch ✅
65
-
66
- 1. `Acta::Handler` base class + `on EventClass do |event| ... end` DSL.
67
- 2. Auto-registration via inheritance + Rails `eager_load_paths`.
68
- 3. Dispatch on emit (sync base handlers).
69
- 4. Registry isolation for specs — `Acta.reset_handlers!`.
70
-
71
- ### M4 — Projections ✅
72
-
73
- 1. `Acta::Projection < Acta::Handler` with sync+transactional contract.
74
- 2. Projections run inside emit transaction.
75
- 3. `ProjectionError` wraps underlying exception + projection class.
76
- 4. `Acta.rebuild!` — truncate projections, replay log, re-run projections.
77
- 5. Replay skips reactors (prep for M5).
78
-
79
- ### M5 — Reactors ✅
80
-
81
- 1. `Acta::Reactor < Acta::Handler` with after-commit + ActiveJob default.
82
- 2. `Acta::ReactorJob` — loads event by uuid, dispatches to reactor class.
83
- 3. `sync true` opt-in.
84
- 4. Skip on replay.
85
- 5. Actor propagation via `Acta::Current` serialized into ActiveJob.
86
-
87
- ### M6 — Commands ✅
88
-
89
- 1. `Acta::Command < Acta::Model` — param validation via AM::Attributes.
90
- 2. `stream :order, key: :order_id` on command — declares aggregate identity.
91
- 3. `on_concurrent_write :raise` — captures stream sequence at instantiation.
92
- 4. `.call(**params)` entry; `emit event` as instance method;
93
- `InvalidCommand` on validation failure.
94
- 5. Auto-loading from `app/commands/`.
95
-
96
- ### M7 — Testing DSL (`Acta::Testing`) ✅
97
-
98
- 1. RSpec matchers: `emit(EventClass).with(...)`, `emit_events([...])`,
99
- `not_to emit_any_events`.
100
- 2. `given_events { ... }` — seeds the log directly without running reactors.
101
- 3. `when_command(cmd)` — runs command, captures emitted events.
102
- 4. `then_emitted(EventClass, **attrs)` / `then_emitted_nothing_else`.
103
- 5. `Acta.test_mode { ... }` — inline reactors for the block.
104
- 6. Replay determinism helper —
105
- `expect_projections_deterministic { ... }`.
106
-
107
- ### M8 — `Acta::Serializable` (AR piggyback) ✅
108
-
109
- 1. Concern adding `to_acta_hash` / `self.from_acta_hash(hash)` on AR classes.
110
- 2. `acta_serialize only: / except:` configuration.
111
- 3. Type dispatch for AR classes in event attributes.
112
- 4. Arrays of AR (`array_of:`) support.
113
- 5. Nested AR-in-AR round-trip.
114
- 6. STI support via `type` column capture.
115
- 7. Schema-drift tolerance — filter unknown keys on deserialize.
116
-
117
- ### M9 — Postgres adapter ✅
118
-
119
- 1. `Acta::Adapters::Postgres` implementation.
120
- 2. Advisory locks (`pg_advisory_xact_lock(hashtext(...))`) per stream.
121
- 3. `jsonb` column type + `uuid` column type + `gen_random_uuid()`.
122
- 4. Shared behaviour specs — `it_behaves_like "an Acta adapter"`.
123
- 5. CI matrix with both SQLite and Postgres.
124
- 6. Concurrency-specific specs exercising genuine concurrent writers.
125
-
126
- ### M10 — v1.0 polish ✅
127
-
128
- 1. Observability via `ActiveSupport::Notifications` —
129
- `acta.event_emitted`, `acta.projection_applied`, `acta.reactor_enqueued`.
130
- 2. Remaining error leaves — `UnknownEventType`, `ReplayError`, gaps.
131
- 3. README rewrite with full worked examples.
132
- 4. Tag v1.0.0.
133
-
134
- ## Milestone dependencies
135
-
136
- ```
137
- M0 → M1 → M2 → M3 → M4 ─┐
138
- └──→ M5 ─┐
139
- M6 ──────┴─→ M7 → M8 → M9 → M10
140
- ```
141
-
142
- M4 and M5 are independent after M3. M6 can start after M2. M8 benefits from
143
- M7. M9 can start anytime after M1 but is most valuable after M8.
144
-
145
- ## Out of scope for v1
146
-
147
- - Upcasters (column reserved)
148
- - Multi-store (latent concept, not exposed)
149
- - MySQL adapter
150
- - `Acta::Saga` / process managers
151
- - LISTEN/NOTIFY or other pub/sub transport
152
- - Snapshots
153
-
154
- ## Quality gates per commit
155
-
156
- - `bundle exec rspec` green
157
- - `bundle exec rubocop` clean
158
- - Change has a spec unless it's pure refactoring
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_model/type"
4
-
5
- module Acta
6
- class ArrayType < ActiveModel::Type::Value
7
- def initialize(element_type)
8
- super()
9
- @element_type = element_type
10
- end
11
-
12
- def cast(value)
13
- return nil if value.nil?
14
-
15
- Array(value).map { |el| @element_type.cast(el) }
16
- end
17
-
18
- def serialize(value)
19
- return nil if value.nil?
20
-
21
- value.map { |el| @element_type.serialize(el) }
22
- end
23
-
24
- def deserialize(value)
25
- return nil if value.nil?
26
-
27
- value.map { |el| @element_type.deserialize(el) }
28
- end
29
- end
30
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_model/type"
4
-
5
- module Acta
6
- class ModelType < ActiveModel::Type::Value
7
- def initialize(wrapped_class)
8
- super()
9
- @wrapped_class = wrapped_class
10
- end
11
-
12
- def cast(value)
13
- case value
14
- when nil then nil
15
- when @wrapped_class then value
16
- when Hash then @wrapped_class.from_acta_hash(value)
17
- else
18
- raise ArgumentError, "Cannot cast #{value.class} (#{value.inspect}) to #{@wrapped_class}"
19
- end
20
- end
21
-
22
- def serialize(value)
23
- return nil if value.nil?
24
-
25
- value.to_acta_hash
26
- end
27
-
28
- def deserialize(value)
29
- cast(value)
30
- end
31
- end
32
- end