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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +229 -33
- data/RELEASING.md +107 -0
- data/docs/README.md +32 -0
- data/docs/event_driven_pub_sub.md +258 -0
- data/docs/upcasters.md +303 -0
- data/gemfiles/rails_7_2.gemfile +20 -0
- data/gemfiles/rails_8_0.gemfile +20 -0
- data/gemfiles/rails_8_1.gemfile +20 -0
- data/lib/acta/errors.rb +38 -0
- data/lib/acta/events_query.rb +51 -4
- data/lib/acta/model.rb +7 -7
- data/lib/acta/reactor.rb +22 -0
- data/lib/acta/record.rb +49 -1
- data/lib/acta/testing/dsl.rb +53 -0
- data/lib/acta/testing.rb +33 -0
- data/lib/acta/types/array.rb +35 -0
- data/lib/acta/types/model.rb +39 -0
- data/lib/acta/upcaster.rb +239 -0
- data/lib/acta/version.rb +1 -1
- data/lib/acta.rb +37 -4
- data/lib/generators/acta/install/install_generator.rb +6 -6
- metadata +23 -16
- data/PLAN.md +0 -158
- data/lib/acta/array_type.rb +0 -30
- data/lib/acta/model_type.rb +0 -32
data/lib/acta/events_query.rb
CHANGED
|
@@ -7,19 +7,27 @@ module Acta
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def last
|
|
10
|
-
|
|
10
|
+
upcast_and_hydrate_one(@scope.last)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def first
|
|
14
|
-
|
|
14
|
+
upcast_and_hydrate_one(@scope.first)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def find_by_uuid(uuid)
|
|
18
|
-
|
|
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
|
-
|
|
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 "
|
|
6
|
-
require_relative "
|
|
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
|
|
15
|
-
# - array_of: Class or array_of: :symbol — wrapped in
|
|
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::
|
|
20
|
+
type = Acta::Types::Array.new(element)
|
|
21
21
|
elsif type.is_a?(Class)
|
|
22
|
-
type = Acta::
|
|
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::
|
|
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
|
-
|
|
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
|
data/lib/acta/testing/dsl.rb
CHANGED
|
@@ -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