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.
@@ -7,19 +7,27 @@ module Acta
7
7
  end
8
8
 
9
9
  def last
10
- hydrate(@scope.last)
10
+ upcast_and_hydrate_one(@scope.last)
11
11
  end
12
12
 
13
13
  def first
14
- hydrate(@scope.first)
14
+ upcast_and_hydrate_one(@scope.first)
15
15
  end
16
16
 
17
17
  def find_by_uuid(uuid)
18
- hydrate(@scope.find_by(uuid:))
18
+ upcast_and_hydrate_one(@scope.find_by(uuid:))
19
19
  end
20
20
 
21
+ # Iterates the full scope through the upcaster pipeline with a SINGLE
22
+ # shared context across every record, matching `Acta.rebuild!` semantics.
23
+ # Stateful upcasters (those that resolve later events from state seeded
24
+ # by earlier ones) depend on this. Single-record lookups
25
+ # (`find_by_uuid`, `first`, `last`) deliberately use a fresh context —
26
+ # there is no prior history to seed it with — and may produce
27
+ # incomplete output for stateful upcasters. See `docs/upcasters.md`.
21
28
  def all
22
- @scope.map { |record| hydrate(record) }
29
+ context = Upcaster::Context.new
30
+ @scope.flat_map { |record| upcast_and_hydrate(record, context) }
23
31
  end
24
32
 
25
33
  def count
@@ -39,8 +47,47 @@ module Acta
39
47
  self.class.new(filtered)
40
48
  end
41
49
 
50
+ # Run a single record through the upcaster pipeline and hydrate every
51
+ # output into a typed Acta::Event. Returns an Array (length 0..N) —
52
+ # callers that expect one event (the historic shape) should use the
53
+ # find_by_uuid/first/last helpers above, which apply a fresh context
54
+ # per call and unwrap to a single event (raising if upcasters drop or
55
+ # fan out, since those shapes aren't meaningful for one-record reads).
56
+ #
57
+ # Acta.rebuild! supplies a single shared context for the full pass.
58
+ def upcast_and_hydrate(record, context)
59
+ Upcaster.upcast(record, context).map { |view| hydrate(view) }
60
+ end
61
+
42
62
  private
43
63
 
64
+ # Single-record helper used by the public lookup methods. Drop and
65
+ # fan-out are rejected here — `find_by_uuid(x)` returning either nil
66
+ # (when an upcaster dropped) or an array (when it fanned out) would
67
+ # silently break every existing caller. Live emit and tests reach for
68
+ # this surface assuming one record → one event.
69
+ def upcast_and_hydrate_one(record)
70
+ return nil unless record
71
+
72
+ results = upcast_and_hydrate(record, fresh_context)
73
+
74
+ case results.length
75
+ when 0
76
+ nil
77
+ when 1
78
+ results.first
79
+ else
80
+ raise UpcasterRegistryError,
81
+ "Upcaster fan-out (#{results.length} events) is not supported on " \
82
+ "single-record reads of #{record.event_type} uuid=#{record.uuid}; " \
83
+ "use Acta.rebuild! or EventsQuery#each, which iterate the pipeline."
84
+ end
85
+ end
86
+
87
+ def fresh_context
88
+ Upcaster::Context.new
89
+ end
90
+
44
91
  def hydrate(record)
45
92
  return nil unless record
46
93
 
data/lib/acta/model.rb CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  require "active_model"
4
4
  require "active_model/attributes"
5
- require_relative "model_type"
6
- require_relative "array_type"
5
+ require_relative "types/model"
6
+ require_relative "types/array"
7
7
 
8
8
  module Acta
9
9
  class Model
@@ -11,15 +11,15 @@ module Acta
11
11
  include ActiveModel::Attributes
12
12
 
13
13
  # Accept:
14
- # - a class as a type (Acta::Model / Acta::Serializable) — wrapped in ModelType
15
- # - array_of: Class or array_of: :symbol — wrapped in ArrayType
14
+ # - a class as a type (Acta::Model / Acta::Serializable) — wrapped in Acta::Types::Model
15
+ # - array_of: Class or array_of: :symbol — wrapped in Acta::Types::Array
16
16
  # - standard symbol types (:string, :integer, ...) — forwarded to AM
17
17
  def self.attribute(name, type = nil, array_of: nil, **options)
18
18
  if array_of
19
19
  element = element_type_for(array_of)
20
- type = Acta::ArrayType.new(element)
20
+ type = Acta::Types::Array.new(element)
21
21
  elsif type.is_a?(Class)
22
- type = Acta::ModelType.new(type)
22
+ type = Acta::Types::Model.new(type)
23
23
  end
24
24
 
25
25
  if type.nil?
@@ -31,7 +31,7 @@ module Acta
31
31
 
32
32
  def self.element_type_for(target)
33
33
  case target
34
- when Class then Acta::ModelType.new(target)
34
+ when Class then Acta::Types::Model.new(target)
35
35
  when Symbol then ActiveModel::Type.lookup(target)
36
36
  else target
37
37
  end
data/lib/acta/reactor.rb CHANGED
@@ -10,6 +10,28 @@ module Acta
10
10
  def sync?
11
11
  @sync == true
12
12
  end
13
+
14
+ # Declares the ActiveJob queue name to enqueue this reactor's job on.
15
+ # Read by Acta's dispatcher when the reactor is async (the default);
16
+ # ignored for `sync!` reactors. With no per-class declaration, the
17
+ # global `Acta.reactor_queue` setting applies; if that's also unset,
18
+ # ActiveJob's `:default` queue is used.
19
+ #
20
+ # class WelcomeEmailReactor < Acta::Reactor
21
+ # queue_as :fast
22
+ # on UserSignedUp do |event|
23
+ # UserMailer.welcome(event.user_id).deliver_later
24
+ # end
25
+ # end
26
+ def queue_as(name)
27
+ @queue_name = name
28
+ end
29
+
30
+ def queue_name
31
+ return @queue_name if defined?(@queue_name)
32
+
33
+ Acta.reactor_queue
34
+ end
13
35
  end
14
36
  end
15
37
  end
data/lib/acta/record.rb CHANGED
@@ -3,8 +3,56 @@
3
3
  require "active_record"
4
4
 
5
5
  module Acta
6
- class Record < ActiveRecord::Base
6
+ # Abstract intermediate. The actual events table lives on `Acta::Record`
7
+ # below; this class exists so hosts can call `connects_to` (which
8
+ # ActiveRecord rejects on concrete classes that have `table_name` set).
9
+ #
10
+ # Default behaviour: inherits from ActiveRecord::Base, no shard or
11
+ # connection routing — Acta::Record uses whatever the host's default
12
+ # connection is.
13
+ #
14
+ # Hosts that need the events table to *share a connection pool* with
15
+ # their own tenant-scoped abstract base (so writes inside a single
16
+ # transaction don't fight across pools on the same SQLite file)
17
+ # re-parent EventsRecord via `Acta.set_events_record_parent!`:
18
+ #
19
+ # # config/initializers/_acta_record_parent.rb
20
+ # class TenantRecord < ActiveRecord::Base
21
+ # self.abstract_class = true
22
+ # end
23
+ # Acta.set_events_record_parent!(TenantRecord)
24
+ # # then call connects_to on TenantRecord — Acta::Record rides along
25
+ #
26
+ # Acta::Record inherits from EventsRecord so any routing applied to
27
+ # EventsRecord (or to a re-parented ancestor) automatically applies.
28
+ class EventsRecord < ActiveRecord::Base
29
+ self.abstract_class = true
30
+ end
31
+
32
+ class Record < EventsRecord
7
33
  self.table_name = "events"
8
34
  self.inheritance_column = nil
9
35
  end
36
+
37
+ # Re-parent EventsRecord (and therefore Record) onto a host-supplied
38
+ # abstract class. Must run BEFORE any query against Acta::Record
39
+ # executes — call from a host initializer after the parent class is
40
+ # defined. Re-defines the two constants so existing references to
41
+ # `Acta::Record` resolve to the new class.
42
+ #
43
+ # Use case: per-tenant SQLite sharding where the host wants events
44
+ # and its own tenant-scoped rows in the same connection pool to
45
+ # avoid SQLite write contention on cross-pool transactions.
46
+ def self.set_events_record_parent!(parent)
47
+ raise ArgumentError, "parent must be an abstract ActiveRecord class" unless parent.is_a?(Class) && parent < ::ActiveRecord::Base && parent.abstract_class?
48
+
49
+ Acta.send(:remove_const, :Record) if Acta.const_defined?(:Record, false)
50
+ Acta.send(:remove_const, :EventsRecord) if Acta.const_defined?(:EventsRecord, false)
51
+
52
+ Acta.const_set(:EventsRecord, Class.new(parent) { self.abstract_class = true })
53
+ Acta.const_set(:Record, Class.new(Acta::EventsRecord) do
54
+ self.table_name = "events"
55
+ self.inheritance_column = nil
56
+ end)
57
+ end
10
58
  end
@@ -72,6 +72,10 @@ module Acta
72
72
  # Assert that running Acta.rebuild! twice produces the same projected
73
73
  # state. The block returns a snapshot of the relevant state (whatever
74
74
  # the app considers authoritative for this projection).
75
+ #
76
+ # Implicitly exercises any registered upcasters — both passes go
77
+ # through the same pipeline, so impure upcasters (state leaking
78
+ # outside the per-replay context) surface as a diff.
75
79
  def ensure_replay_deterministic(&snapshot)
76
80
  Acta.rebuild!
77
81
  first = snapshot.call
@@ -85,6 +89,55 @@ module Acta
85
89
  "second pass: #{second.inspect}"
86
90
  )
87
91
  end
92
+
93
+ # Insert an event row directly into the store, bypassing `Acta.emit`.
94
+ # Used by upcaster specs to seed events at arbitrary `event_version`
95
+ # values — `Acta.emit` always stamps the current code's version, so
96
+ # it can't simulate a pre-migration row.
97
+ #
98
+ # acta_seed_event(type: "ItemAdded", event_version: 1,
99
+ # payload: { "item_id" => "g_1", "item_type" => "goal" })
100
+ def acta_seed_event(type:, payload:, event_version: 1, actor: nil,
101
+ stream_type: nil, stream_key: nil, occurred_at: nil, uuid: nil)
102
+ actor ||= Acta::Current.actor || Acta::Actor.new(
103
+ type: "system", id: "rspec", source: "test"
104
+ )
105
+
106
+ Acta::Record.create!(
107
+ uuid: uuid || SecureRandom.uuid,
108
+ event_type: type.to_s,
109
+ event_version: event_version,
110
+ payload: payload,
111
+ actor_type: actor.type,
112
+ actor_id: actor.id,
113
+ source: actor.source,
114
+ metadata: actor.metadata.empty? ? nil : actor.metadata,
115
+ stream_type: stream_type&.to_s,
116
+ stream_key: stream_key,
117
+ occurred_at: occurred_at || Time.current,
118
+ recorded_at: Time.current
119
+ )
120
+ end
121
+
122
+ # End-to-end upcaster fixture: register upcasters, seed events at the
123
+ # given versions, run `Acta.rebuild!`. The caller asserts on whatever
124
+ # projection state matters for the migration under test.
125
+ #
126
+ # acta_replay(
127
+ # upcasters: [Scaff::WorkspaceMigrationUpcasters],
128
+ # events: [
129
+ # { type: "Scaff::ItemCreated", event_version: 1,
130
+ # payload: { "item_id" => "g_1", "item_type" => "goal", "title" => "Foo" } },
131
+ # { type: "Scaff::ItemCreated", event_version: 1,
132
+ # payload: { "item_id" => "i_2", "parent_id" => "g_1", "title" => "Bar" } }
133
+ # ]
134
+ # )
135
+ # expect(Workspace.pluck(:id)).to eq(%w[g_1])
136
+ def acta_replay(events:, upcasters: [])
137
+ upcasters.each { |u| Acta.register_upcaster(u) }
138
+ events.each { |attrs| acta_seed_event(**attrs) }
139
+ Acta.rebuild!
140
+ end
88
141
  end
89
142
  end
90
143
  end
data/lib/acta/testing.rb CHANGED
@@ -6,6 +6,26 @@ module Acta
6
6
  module Testing
7
7
  DEFAULT_ACTOR_ATTRIBUTES = { type: "system", id: "rspec", source: "test" }.freeze
8
8
 
9
+ # Wraps writes to `acta_managed!` AR models so the projection-write guard
10
+ # accepts them. Useful for fixtures, factories, and ad-hoc setup that
11
+ # needs to bypass the command + event + projection chain.
12
+ #
13
+ # # spec/rails_helper.rb
14
+ # require "acta/testing"
15
+ # RSpec.configure do |config|
16
+ # Acta::Testing.projection_writes_helper!(config)
17
+ # end
18
+ #
19
+ # # in any spec:
20
+ # with_projection_writes do
21
+ # Trail.create!(name: "Crank It Up", zone: zone)
22
+ # end
23
+ module ProjectionWritesHelpers
24
+ def with_projection_writes(&block)
25
+ Acta::Projection.applying!(&block)
26
+ end
27
+ end
28
+
9
29
  module_function
10
30
 
11
31
  # Runs the given block with ActiveJob's :inline adapter, so async
@@ -46,5 +66,18 @@ module Acta
46
66
  Acta::Current.reset
47
67
  end
48
68
  end
69
+
70
+ # Includes `with_projection_writes` into every RSpec example. The helper
71
+ # forwards to `Acta::Projection.applying!`, so blocks under it pass the
72
+ # `acta_managed!` write guard. See ProjectionWritesHelpers.
73
+ #
74
+ # # spec/rails_helper.rb
75
+ # require "acta/testing"
76
+ # RSpec.configure do |config|
77
+ # Acta::Testing.projection_writes_helper!(config)
78
+ # end
79
+ def projection_writes_helper!(config)
80
+ config.include(ProjectionWritesHelpers)
81
+ end
49
82
  end
50
83
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+
5
+ module Acta
6
+ module Types
7
+ # Wraps any other Acta type as a list-of-element type. Used internally
8
+ # by `attribute :foo, array_of: Class` (or `array_of: :symbol`); not
9
+ # constructed directly by consumers.
10
+ class Array < ActiveModel::Type::Value
11
+ def initialize(element_type)
12
+ super()
13
+ @element_type = element_type
14
+ end
15
+
16
+ def cast(value)
17
+ return nil if value.nil?
18
+
19
+ Kernel.Array(value).map { |el| @element_type.cast(el) }
20
+ end
21
+
22
+ def serialize(value)
23
+ return nil if value.nil?
24
+
25
+ value.map { |el| @element_type.serialize(el) }
26
+ end
27
+
28
+ def deserialize(value)
29
+ return nil if value.nil?
30
+
31
+ value.map { |el| @element_type.deserialize(el) }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+
5
+ module Acta
6
+ module Types
7
+ # Wraps an Acta::Model subclass (or any class with `to_acta_hash` /
8
+ # `from_acta_hash`, e.g. AR classes that include Acta::Serializable)
9
+ # so it can be used as an `attribute` type on another Acta::Model.
10
+ # The wrapping is automatic — `attribute :location, GeoPoint` invokes
11
+ # this internally; consumers don't construct it directly.
12
+ class Model < ActiveModel::Type::Value
13
+ def initialize(wrapped_class)
14
+ super()
15
+ @wrapped_class = wrapped_class
16
+ end
17
+
18
+ def cast(value)
19
+ case value
20
+ when nil then nil
21
+ when @wrapped_class then value
22
+ when Hash then @wrapped_class.from_acta_hash(value)
23
+ else
24
+ raise ArgumentError, "Cannot cast #{value.class} (#{value.inspect}) to #{@wrapped_class}"
25
+ end
26
+ end
27
+
28
+ def serialize(value)
29
+ return nil if value.nil?
30
+
31
+ value.to_acta_hash
32
+ end
33
+
34
+ def deserialize(value)
35
+ cast(value)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ # Replay-time event transformation. Apps declare upcasters when an event
5
+ # type's shape changes between schema versions; the pipeline transforms
6
+ # stored records on read so projections see them at the latest shape.
7
+ # See `docs/upcasters.md` for the end-to-end recipe.
8
+ #
9
+ # module Scaff
10
+ # class WorkspaceMigrationUpcasters
11
+ # include Acta::Upcaster
12
+ #
13
+ # upcasts "Scaff::ItemCreated", from: 1, to: 2 do |event, context|
14
+ # payload = event.payload
15
+ # if payload["item_type"] == "goal"
16
+ # context[:goal_to_workspace][payload["item_id"]] = payload["item_id"]
17
+ # event.upcast_to(
18
+ # type: "Scaff::WorkspaceCreated",
19
+ # payload: { "workspace_id" => payload["item_id"], "title" => payload["title"] },
20
+ # schema_version: 2
21
+ # )
22
+ # else
23
+ # event.upcast_to(payload: payload.merge("workspace_id" => "..."), schema_version: 2)
24
+ # end
25
+ # end
26
+ # end
27
+ # end
28
+ #
29
+ # Acta.register_upcaster(Scaff::WorkspaceMigrationUpcasters)
30
+ #
31
+ # Upcasters run pre-hydration during every read (`Acta.rebuild!`,
32
+ # `ReactorJob#perform`, the events admin, test fixtures) — apps can
33
+ # safely delete an old event class once a rename upcaster is in place.
34
+ # The live emit path is exempt: emitted events carry the current code's
35
+ # `event_version` and are dispatched in-memory before any read happens.
36
+ module Upcaster
37
+ # Identity sentinel — `upcasts "Foo", from: N, to: N, &Acta::Upcaster::NO_OP`
38
+ # declares the post-migration record at version N as a no-op pass-through
39
+ # (e.g. a `GoalPromotedToWorkspace` event whose effect is already produced
40
+ # by upcasting earlier events).
41
+ NO_OP = lambda { |event, _context| event }.freeze
42
+
43
+ def self.included(base)
44
+ base.extend(ClassMethods)
45
+ end
46
+
47
+ module ClassMethods
48
+ # Declare a transform. `from` and `to` are integer schema versions on
49
+ # the same event type; `to` must be >= `from`. The block receives an
50
+ # upcast-shaped record and the per-replay context, and must return
51
+ # either a single upcasted record, an array (1-to-many — each branch
52
+ # continues chaining independently), `nil`/`[]` (drop on replay), or
53
+ # call `context.fail_replay!(reason)`.
54
+ def upcasts(event_type, from:, to:, &block)
55
+ raise UpcasterRegistryError, "from must be an Integer" unless from.is_a?(Integer)
56
+ raise UpcasterRegistryError, "to must be an Integer" unless to.is_a?(Integer)
57
+ raise UpcasterRegistryError, "to (#{to}) must be >= from (#{from})" if to < from
58
+ raise UpcasterRegistryError, "block required for upcasts(#{event_type.inspect}, from: #{from}, to: #{to})" unless block
59
+
60
+ upcaster_registrations << { event_type: event_type.to_s, from:, to:, block: }
61
+ end
62
+
63
+ def upcaster_registrations
64
+ @upcaster_registrations ||= []
65
+ end
66
+ end
67
+
68
+ # Per-replay state carrier passed to every upcaster block. Hash-shaped
69
+ # by default — `context[:goal_to_workspace] ||= {}`. Lives the length
70
+ # of one replay; never persisted across runs.
71
+ class Context
72
+ class FailReplay < StandardError; end
73
+
74
+ def initialize
75
+ @store = {}
76
+ end
77
+
78
+ def [](key)
79
+ @store[key] ||= {}
80
+ end
81
+
82
+ def []=(key, value)
83
+ @store[key] = value
84
+ end
85
+
86
+ def key?(key)
87
+ @store.key?(key)
88
+ end
89
+
90
+ # Halt the replay with a clear reason. Wrapped by the pipeline into
91
+ # `Acta::ReplayHaltedByUpcaster`, which carries the offending record.
92
+ def fail_replay!(reason)
93
+ raise FailReplay, reason
94
+ end
95
+ end
96
+
97
+ # In-memory record shape passed to upcaster blocks. Wraps a backing
98
+ # `Acta::Record` (the row as stored) with optional overlays for
99
+ # `event_type`, `event_version`, and `payload` — upcasters mutate
100
+ # *only* the overlays, never the stored row.
101
+ class View
102
+ ENVELOPE_FIELDS = %i[id uuid occurred_at recorded_at actor_type actor_id source metadata stream_type stream_key stream_sequence].freeze
103
+
104
+ attr_reader :base, :event_type, :event_version, :payload
105
+
106
+ def initialize(base, event_type: nil, event_version: nil, payload: nil)
107
+ @base = base
108
+ @event_type = event_type || base.event_type
109
+ @event_version = event_version || base.event_version
110
+ @payload = payload || (base.payload || {})
111
+ end
112
+
113
+ ENVELOPE_FIELDS.each do |field|
114
+ define_method(field) { base.public_send(field) }
115
+ end
116
+
117
+ # Produce a new View with the supplied attributes overlaid. `type`
118
+ # defaults to the current event_type; `payload` defaults to the
119
+ # current payload; `schema_version` is required and replaces
120
+ # `event_version`. The original (and the underlying Record) are
121
+ # untouched.
122
+ def upcast_to(type: nil, payload: nil, schema_version:)
123
+ raise ArgumentError, "schema_version required" if schema_version.nil?
124
+
125
+ View.new(
126
+ base,
127
+ event_type: type || @event_type,
128
+ event_version: schema_version,
129
+ payload: payload || @payload
130
+ )
131
+ end
132
+ end
133
+
134
+ # Holds the merged set of `(event_type, from) → block` entries from
135
+ # every registered upcaster class. Also tracks the max `to` per event
136
+ # type so the pipeline can flag future-version records cleanly.
137
+ class Registry
138
+ def initialize
139
+ @by_key = {}
140
+ @latest_to = Hash.new(0)
141
+ @registered_classes = []
142
+ end
143
+
144
+ def register(upcaster_class)
145
+ return if @registered_classes.include?(upcaster_class)
146
+
147
+ upcaster_class.upcaster_registrations.each do |reg|
148
+ key = [ reg[:event_type], reg[:from] ]
149
+ if @by_key.key?(key)
150
+ existing = @by_key[key]
151
+ raise UpcasterRegistryError,
152
+ "Conflicting upcasters for #{reg[:event_type].inspect} v#{reg[:from]}: " \
153
+ "#{existing[:owner].name} already registered the (event_type, from) pair; " \
154
+ "#{upcaster_class.name} tried to register it again."
155
+ end
156
+
157
+ @by_key[key] = reg.merge(owner: upcaster_class)
158
+ @latest_to[reg[:event_type]] = [ @latest_to[reg[:event_type]], reg[:to] ].max
159
+ end
160
+
161
+ @registered_classes << upcaster_class
162
+ end
163
+
164
+ def find(event_type, from)
165
+ @by_key[[ event_type, from ]]
166
+ end
167
+
168
+ def latest_for(event_type)
169
+ @latest_to[event_type]
170
+ end
171
+
172
+ def empty?
173
+ @by_key.empty?
174
+ end
175
+
176
+ def clear!
177
+ @by_key.clear
178
+ @latest_to.clear
179
+ @registered_classes.clear
180
+ end
181
+ end
182
+
183
+ # Walk a record through every matching upcaster, returning 0..N
184
+ # upcasted records. Identity when no upcaster matches. Handles:
185
+ # - chain: block returns a single record → loop continues at new (event_type, event_version)
186
+ # - 1-to-many: block returns an array → each branch recurses (so chaining + fan-out compose)
187
+ # - drop: block returns nil or [] → record produces no projection input
188
+ # - fail: block calls `context.fail_replay!` → halts with `ReplayHaltedByUpcaster`
189
+ # - future ver: stored event_version exceeds anything we can reach → `FutureSchemaVersion`
190
+ def self.upcast(record, context, registry: Acta.upcaster_registry)
191
+ origin = record.respond_to?(:base) ? record.base : record
192
+ current = record.is_a?(View) ? record : View.new(record)
193
+ return [ current ] if registry.empty?
194
+
195
+ loop do
196
+ reg = registry.find(current.event_type, current.event_version)
197
+
198
+ unless reg
199
+ known_max = registry.latest_for(current.event_type)
200
+ if known_max.positive? && current.event_version > known_max
201
+ raise FutureSchemaVersion.new(record: origin, latest_known_version: known_max)
202
+ end
203
+
204
+ break
205
+ end
206
+
207
+ result = begin
208
+ reg[:block].call(current, context)
209
+ rescue Context::FailReplay => e
210
+ raise ReplayHaltedByUpcaster.new(record: origin, reason: e.message)
211
+ end
212
+
213
+ return [] if result.nil? || (result.is_a?(Array) && result.empty?)
214
+
215
+ if result.is_a?(Array)
216
+ return result.flat_map { |branch| upcast(branch, context, registry: registry) }
217
+ end
218
+
219
+ unless result.is_a?(View)
220
+ raise UpcasterRegistryError,
221
+ "Upcaster #{reg[:owner].name} for #{current.event_type} v#{current.event_version} " \
222
+ "returned #{result.class} — expected an Acta::Upcaster::View " \
223
+ "(use `event.upcast_to(...)` to produce one)."
224
+ end
225
+
226
+ if result.event_version == current.event_version && result.event_type == current.event_type
227
+ # Identity at the current version (e.g. NO_OP). Stop the loop —
228
+ # otherwise we'd recurse forever on the same (type, version) key.
229
+ current = result
230
+ break
231
+ end
232
+
233
+ current = result
234
+ end
235
+
236
+ [ current ]
237
+ end
238
+ end
239
+ end
data/lib/acta/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acta
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0.alpha.1"
5
5
  end