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.
- checksums.yaml +7 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +210 -0
- data/LICENSE +21 -0
- data/PLAN.md +158 -0
- data/README.md +559 -0
- data/Rakefile +12 -0
- data/app/controllers/acta/web/application_controller.rb +10 -0
- data/app/controllers/acta/web/events_controller.rb +37 -0
- data/app/helpers/acta/web/application_helper.rb +106 -0
- data/app/views/acta/web/events/index.html.erb +312 -0
- data/app/views/acta/web/events/show.html.erb +72 -0
- data/app/views/layouts/acta/web/application.html.erb +594 -0
- data/config/routes.rb +4 -0
- data/lib/acta/actor.rb +34 -0
- data/lib/acta/adapters/base.rb +59 -0
- data/lib/acta/adapters/postgres.rb +73 -0
- data/lib/acta/adapters/sqlite.rb +58 -0
- data/lib/acta/adapters.rb +19 -0
- data/lib/acta/array_type.rb +30 -0
- data/lib/acta/command.rb +48 -0
- data/lib/acta/current.rb +10 -0
- data/lib/acta/errors.rb +102 -0
- data/lib/acta/event.rb +80 -0
- data/lib/acta/events_query.rb +73 -0
- data/lib/acta/handler.rb +9 -0
- data/lib/acta/model.rb +58 -0
- data/lib/acta/model_type.rb +32 -0
- data/lib/acta/projection.rb +64 -0
- data/lib/acta/projection_managed.rb +108 -0
- data/lib/acta/railtie.rb +65 -0
- data/lib/acta/reactor.rb +15 -0
- data/lib/acta/reactor_job.rb +19 -0
- data/lib/acta/record.rb +10 -0
- data/lib/acta/schema.rb +12 -0
- data/lib/acta/serializable.rb +48 -0
- data/lib/acta/testing/dsl.rb +90 -0
- data/lib/acta/testing/matchers.rb +77 -0
- data/lib/acta/testing.rb +50 -0
- data/lib/acta/types/encrypted_string.rb +63 -0
- data/lib/acta/version.rb +5 -0
- data/lib/acta/web/engine.rb +13 -0
- data/lib/acta/web/events_query.rb +81 -0
- data/lib/acta/web.rb +45 -0
- data/lib/acta.rb +296 -0
- data/lib/generators/acta/install/install_generator.rb +23 -0
- data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
- data/sig/acta.rbs +4 -0
- metadata +152 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acta
|
|
4
|
+
module Adapters
|
|
5
|
+
class Postgres < Base
|
|
6
|
+
def uuid_column_type
|
|
7
|
+
:uuid
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def json_column_type
|
|
11
|
+
:jsonb
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def insert_event(attributes)
|
|
15
|
+
stream_type = attributes[:stream_type]
|
|
16
|
+
stream_key = attributes[:stream_key]
|
|
17
|
+
|
|
18
|
+
if stream_type && stream_key
|
|
19
|
+
insert_streamed(attributes, stream_type:, stream_key:)
|
|
20
|
+
else
|
|
21
|
+
Acta::Record.create!(**attributes)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch_records
|
|
26
|
+
Acta::Record.order(:id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def insert_streamed(attributes, stream_type:, stream_key:)
|
|
32
|
+
Acta::Record.transaction(requires_new: true) do
|
|
33
|
+
acquire_stream_lock(stream_type, stream_key)
|
|
34
|
+
sequence = compute_next_sequence(stream_type, stream_key)
|
|
35
|
+
Acta::Record.create!(**attributes, stream_sequence: sequence)
|
|
36
|
+
end
|
|
37
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
38
|
+
raise unless stream_conflict?(e)
|
|
39
|
+
|
|
40
|
+
actual = current_stream_max(stream_type, stream_key) || 0
|
|
41
|
+
raise Acta::VersionConflict.new(
|
|
42
|
+
stream_type:,
|
|
43
|
+
stream_key:,
|
|
44
|
+
expected_version: actual + 1,
|
|
45
|
+
actual_version: actual
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def acquire_stream_lock(stream_type, stream_key)
|
|
50
|
+
key = "#{stream_type}:#{stream_key}"
|
|
51
|
+
Acta::Record.connection.execute(
|
|
52
|
+
"SELECT pg_advisory_xact_lock(hashtext(#{ActiveRecord::Base.connection.quote(key)}))"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def compute_next_sequence(stream_type, stream_key)
|
|
57
|
+
(current_stream_max(stream_type, stream_key) || 0) + 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def current_stream_max(stream_type, stream_key)
|
|
61
|
+
Acta::Record
|
|
62
|
+
.where(stream_type:, stream_key:)
|
|
63
|
+
.maximum(:stream_sequence)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def stream_conflict?(error)
|
|
67
|
+
message = error.message
|
|
68
|
+
message.include?("stream_sequence") ||
|
|
69
|
+
message.include?("index_events_on_stream_identity")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acta
|
|
4
|
+
module Adapters
|
|
5
|
+
class SQLite < Base
|
|
6
|
+
def insert_event(attributes)
|
|
7
|
+
stream_type = attributes[:stream_type]
|
|
8
|
+
stream_key = attributes[:stream_key]
|
|
9
|
+
|
|
10
|
+
if stream_type && stream_key
|
|
11
|
+
insert_streamed(attributes, stream_type:, stream_key:)
|
|
12
|
+
else
|
|
13
|
+
Acta::Record.create!(**attributes)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch_records
|
|
18
|
+
Acta::Record.order(:id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def insert_streamed(attributes, stream_type:, stream_key:)
|
|
24
|
+
sequence = compute_next_sequence(stream_type, stream_key)
|
|
25
|
+
|
|
26
|
+
Acta::Record.transaction(requires_new: true) do
|
|
27
|
+
Acta::Record.create!(**attributes, stream_sequence: sequence)
|
|
28
|
+
end
|
|
29
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
30
|
+
raise unless stream_conflict?(e)
|
|
31
|
+
|
|
32
|
+
actual = current_stream_max(stream_type, stream_key) || 0
|
|
33
|
+
raise Acta::VersionConflict.new(
|
|
34
|
+
stream_type:,
|
|
35
|
+
stream_key:,
|
|
36
|
+
expected_version: sequence,
|
|
37
|
+
actual_version: actual
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def compute_next_sequence(stream_type, stream_key)
|
|
42
|
+
(current_stream_max(stream_type, stream_key) || 0) + 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def current_stream_max(stream_type, stream_key)
|
|
46
|
+
Acta::Record
|
|
47
|
+
.where(stream_type:, stream_key:)
|
|
48
|
+
.maximum(:stream_sequence)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stream_conflict?(error)
|
|
52
|
+
message = error.message
|
|
53
|
+
message.include?("stream_sequence") ||
|
|
54
|
+
message.include?("index_events_on_stream_identity")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapters/base"
|
|
4
|
+
require_relative "adapters/sqlite"
|
|
5
|
+
require_relative "adapters/postgres"
|
|
6
|
+
|
|
7
|
+
module Acta
|
|
8
|
+
module Adapters
|
|
9
|
+
def self.for(connection)
|
|
10
|
+
name = connection.adapter_name.downcase
|
|
11
|
+
case name
|
|
12
|
+
when /sqlite/ then SQLite.new
|
|
13
|
+
when /postgres/, /postgis/ then Postgres.new
|
|
14
|
+
else
|
|
15
|
+
raise AdapterError, "No Acta adapter for #{name.inspect}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
data/lib/acta/command.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acta
|
|
4
|
+
class Command < Model
|
|
5
|
+
class << self
|
|
6
|
+
alias_method :param, :attribute
|
|
7
|
+
|
|
8
|
+
# Instantiate the command with the given params, run it, and return
|
|
9
|
+
# the command instance. Callers that need to know what the command
|
|
10
|
+
# emitted read it back off the instance:
|
|
11
|
+
#
|
|
12
|
+
# cmd = CreateOrder.call(customer_id: "c_1")
|
|
13
|
+
# cmd.emitted_events # => [<OrderCreated …>]
|
|
14
|
+
# cmd.emitted_events.find { _1.is_a?(OrderCreated) }.order_id
|
|
15
|
+
#
|
|
16
|
+
# Returning the instance keeps the framework honest about
|
|
17
|
+
# multiplicity — commands can emit zero, one, or many events, and
|
|
18
|
+
# the caller (who knows the domain) picks what matters. The
|
|
19
|
+
# framework does not invent a "primary" event.
|
|
20
|
+
def call(**params)
|
|
21
|
+
instance = new(**params)
|
|
22
|
+
instance.call
|
|
23
|
+
instance
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(**params)
|
|
28
|
+
super
|
|
29
|
+
raise InvalidCommand, self unless valid?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Emit an event. Pass `if_version:` to assert the stream's current
|
|
33
|
+
# high-water mark for optimistic locking — see Acta.version_of.
|
|
34
|
+
def emit(event, if_version: nil)
|
|
35
|
+
Acta.emit(event, if_version: if_version)
|
|
36
|
+
emitted_events << event
|
|
37
|
+
event
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Every event emitted during this command instance's invocation, in
|
|
41
|
+
# the order `emit` was called. Empty until #call runs; cascading
|
|
42
|
+
# commands invoked from inside #call produce events in their own
|
|
43
|
+
# instances, not this one.
|
|
44
|
+
def emitted_events
|
|
45
|
+
@emitted_events ||= []
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/acta/current.rb
ADDED
data/lib/acta/errors.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acta
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class InvalidEvent < Error
|
|
7
|
+
attr_reader :event
|
|
8
|
+
|
|
9
|
+
def initialize(event)
|
|
10
|
+
@event = event
|
|
11
|
+
super("Event is invalid: #{event.errors.full_messages.join(', ')}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class CommandError < Error; end
|
|
16
|
+
|
|
17
|
+
class InvalidCommand < CommandError
|
|
18
|
+
attr_reader :command
|
|
19
|
+
|
|
20
|
+
def initialize(command)
|
|
21
|
+
@command = command
|
|
22
|
+
super("Command #{command.class} is invalid: #{command.errors.full_messages.join(', ')}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ProjectionError < Error
|
|
27
|
+
attr_reader :event, :projection_class, :original
|
|
28
|
+
|
|
29
|
+
def initialize(event:, projection_class:, original:)
|
|
30
|
+
@event = event
|
|
31
|
+
@projection_class = projection_class
|
|
32
|
+
@original = original
|
|
33
|
+
super("Projection #{projection_class} failed on #{event.event_type}: #{original.message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class VersionConflict < Error
|
|
38
|
+
attr_reader :stream_type, :stream_key, :expected_version, :actual_version
|
|
39
|
+
|
|
40
|
+
def initialize(stream_type:, stream_key:, expected_version:, actual_version:)
|
|
41
|
+
@stream_type = stream_type
|
|
42
|
+
@stream_key = stream_key
|
|
43
|
+
@expected_version = expected_version
|
|
44
|
+
@actual_version = actual_version
|
|
45
|
+
super(
|
|
46
|
+
"Version conflict on stream #{stream_type}/#{stream_key}: " \
|
|
47
|
+
"expected version #{expected_version}, stream is at version #{actual_version}"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class MissingActor < Error; end
|
|
53
|
+
class ConfigurationError < Error; end
|
|
54
|
+
class AdapterError < Error; end
|
|
55
|
+
|
|
56
|
+
class UnknownEventType < Error
|
|
57
|
+
attr_reader :event_type
|
|
58
|
+
|
|
59
|
+
def initialize(event_type)
|
|
60
|
+
@event_type = event_type
|
|
61
|
+
super("Unknown event type #{event_type.inspect} — class is not loaded")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class ReplayError < Error
|
|
66
|
+
attr_reader :record, :original
|
|
67
|
+
|
|
68
|
+
def initialize(record:, original:)
|
|
69
|
+
@record = record
|
|
70
|
+
@original = original
|
|
71
|
+
super("Replay failed on event id=#{record.id} uuid=#{record.uuid} (#{record.event_type}): #{original.message}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class TruncateOrderError < Error
|
|
76
|
+
attr_reader :projections
|
|
77
|
+
|
|
78
|
+
def initialize(projections)
|
|
79
|
+
@projections = projections
|
|
80
|
+
super(
|
|
81
|
+
"Cannot determine a safe truncate order for projections #{projections.map(&:name).inspect} — " \
|
|
82
|
+
"their declared `truncates` classes form a foreign-key cycle. " \
|
|
83
|
+
"Either break the cycle or have one projection truncate the other's tables itself."
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class ProjectionWriteError < Error
|
|
89
|
+
attr_reader :model_class, :write_method
|
|
90
|
+
|
|
91
|
+
def initialize(model_class:, write_method:)
|
|
92
|
+
@model_class = model_class
|
|
93
|
+
@write_method = write_method
|
|
94
|
+
super(
|
|
95
|
+
"Direct #{write_method} on #{model_class.name} bypasses the event log. " \
|
|
96
|
+
"#{model_class.name} is acta_managed! — its rows are owned by an Acta::Projection. " \
|
|
97
|
+
"Emit an event so the projection can update the row, or wrap intentional " \
|
|
98
|
+
"out-of-band writes in `Acta::Projection.applying! { ... }` (fixtures, migrations, backfills)."
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/acta/event.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "active_support/core_ext/time"
|
|
5
|
+
|
|
6
|
+
module Acta
|
|
7
|
+
class Event < Model
|
|
8
|
+
attr_accessor :uuid, :occurred_at, :recorded_at, :actor
|
|
9
|
+
|
|
10
|
+
ENVELOPE_KEYS = %i[ uuid occurred_at recorded_at actor ].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(**attrs)
|
|
13
|
+
envelope = attrs.slice(*ENVELOPE_KEYS)
|
|
14
|
+
payload = attrs.except(*ENVELOPE_KEYS)
|
|
15
|
+
|
|
16
|
+
@uuid = envelope[:uuid] || SecureRandom.uuid
|
|
17
|
+
@occurred_at = envelope[:occurred_at] || Time.current
|
|
18
|
+
@recorded_at = envelope[:recorded_at]
|
|
19
|
+
@actor = envelope.key?(:actor) ? envelope[:actor] : Acta::Current.actor
|
|
20
|
+
|
|
21
|
+
super(**payload)
|
|
22
|
+
|
|
23
|
+
raise InvalidEvent, self unless valid?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def event_type
|
|
27
|
+
self.class.event_type
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def event_version
|
|
31
|
+
self.class.event_version
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def payload_hash
|
|
35
|
+
to_acta_hash
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.event_type
|
|
39
|
+
name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.event_version
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.stream(type, key:)
|
|
47
|
+
@stream_type = type.to_s
|
|
48
|
+
@stream_key_attribute = key
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
attr_reader :stream_type, :stream_key_attribute
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stream_type
|
|
56
|
+
self.class.stream_type
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stream_key
|
|
60
|
+
attribute = self.class.stream_key_attribute
|
|
61
|
+
return nil if attribute.nil?
|
|
62
|
+
|
|
63
|
+
public_send(attribute)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Reconstructs an event from a stored record. Routes payload values
|
|
67
|
+
# through their type's `deserialize` (so encrypted attributes decrypt
|
|
68
|
+
# back to plaintext) before construction.
|
|
69
|
+
def self.from_acta_record(envelope:, payload:)
|
|
70
|
+
types = attribute_types
|
|
71
|
+
decoded = (payload || {}).each_with_object({}) do |(k, v), acc|
|
|
72
|
+
key = k.to_s
|
|
73
|
+
next unless types.key?(key)
|
|
74
|
+
|
|
75
|
+
acc[key.to_sym] = types[key].deserialize(v)
|
|
76
|
+
end
|
|
77
|
+
new(**envelope, **decoded)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acta
|
|
4
|
+
class EventsQuery
|
|
5
|
+
def initialize(scope)
|
|
6
|
+
@scope = scope
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def last
|
|
10
|
+
hydrate(@scope.last)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def first
|
|
14
|
+
hydrate(@scope.first)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def find_by_uuid(uuid)
|
|
18
|
+
hydrate(@scope.find_by(uuid:))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all
|
|
22
|
+
@scope.map { |record| hydrate(record) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def count
|
|
26
|
+
@scope.count
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def each(&)
|
|
30
|
+
all.each(&)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
include Enumerable
|
|
34
|
+
|
|
35
|
+
def for_stream(type:, key:)
|
|
36
|
+
filtered = @scope
|
|
37
|
+
.where(stream_type: type.to_s, stream_key: key)
|
|
38
|
+
.reorder(:stream_sequence)
|
|
39
|
+
self.class.new(filtered)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def hydrate(record)
|
|
45
|
+
return nil unless record
|
|
46
|
+
|
|
47
|
+
klass = begin
|
|
48
|
+
Object.const_get(record.event_type)
|
|
49
|
+
rescue NameError
|
|
50
|
+
raise Acta::UnknownEventType, record.event_type
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
envelope = {
|
|
54
|
+
uuid: record.uuid,
|
|
55
|
+
occurred_at: record.occurred_at,
|
|
56
|
+
recorded_at: record.recorded_at,
|
|
57
|
+
actor: build_actor(record)
|
|
58
|
+
}
|
|
59
|
+
klass.from_acta_record(envelope:, payload: record.payload || {})
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_actor(record)
|
|
63
|
+
return nil if record.actor_type.nil?
|
|
64
|
+
|
|
65
|
+
Actor.new(
|
|
66
|
+
type: record.actor_type,
|
|
67
|
+
id: record.actor_id,
|
|
68
|
+
source: record.source,
|
|
69
|
+
metadata: record.metadata || {}
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/acta/handler.rb
ADDED
data/lib/acta/model.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "active_model/attributes"
|
|
5
|
+
require_relative "model_type"
|
|
6
|
+
require_relative "array_type"
|
|
7
|
+
|
|
8
|
+
module Acta
|
|
9
|
+
class Model
|
|
10
|
+
include ActiveModel::Model
|
|
11
|
+
include ActiveModel::Attributes
|
|
12
|
+
|
|
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
|
|
16
|
+
# - standard symbol types (:string, :integer, ...) — forwarded to AM
|
|
17
|
+
def self.attribute(name, type = nil, array_of: nil, **options)
|
|
18
|
+
if array_of
|
|
19
|
+
element = element_type_for(array_of)
|
|
20
|
+
type = Acta::ArrayType.new(element)
|
|
21
|
+
elsif type.is_a?(Class)
|
|
22
|
+
type = Acta::ModelType.new(type)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if type.nil?
|
|
26
|
+
super(name, **options)
|
|
27
|
+
else
|
|
28
|
+
super(name, type, **options)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.element_type_for(target)
|
|
33
|
+
case target
|
|
34
|
+
when Class then Acta::ModelType.new(target)
|
|
35
|
+
when Symbol then ActiveModel::Type.lookup(target)
|
|
36
|
+
else target
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
private_class_method :element_type_for
|
|
40
|
+
|
|
41
|
+
def to_acta_hash
|
|
42
|
+
self.class.attribute_types.each_with_object({}) do |(name, type), hash|
|
|
43
|
+
hash[name] = type.serialize(public_send(name))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.from_acta_hash(hash)
|
|
48
|
+
types = attribute_types
|
|
49
|
+
filtered = hash.each_with_object({}) do |(k, v), acc|
|
|
50
|
+
key = k.to_s
|
|
51
|
+
next unless types.key?(key)
|
|
52
|
+
|
|
53
|
+
acc[key.to_sym] = types[key].deserialize(v)
|
|
54
|
+
end
|
|
55
|
+
new(**filtered)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Acta
|
|
4
|
+
class Projection < Handler
|
|
5
|
+
APPLYING_FLAG = :acta_projection_applying
|
|
6
|
+
|
|
7
|
+
def self.inherited(subclass)
|
|
8
|
+
super
|
|
9
|
+
Acta.register_projection(subclass)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Declare the AR classes whose rows this projection owns. Acta uses these
|
|
13
|
+
# declarations both as the default `truncate!` target list and as input to
|
|
14
|
+
# `Acta.rebuild!`'s cross-projection ordering — projections whose tables
|
|
15
|
+
# are FK-referenced by another projection's tables run first, so children
|
|
16
|
+
# are deleted before their parents.
|
|
17
|
+
#
|
|
18
|
+
# class CatalogProjection < Acta::Projection
|
|
19
|
+
# truncates Trail, Zone # within-projection: child first, parent second
|
|
20
|
+
#
|
|
21
|
+
# on ZoneRegistered { |e| Zone.create!(...) }
|
|
22
|
+
# on TrailRegistered { |e| Trail.create!(...) }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# Pass classes in safe within-projection order (children before parents);
|
|
26
|
+
# Acta only orders projections relative to each other via the global FK
|
|
27
|
+
# graph, not the within-projection list.
|
|
28
|
+
def self.truncates(*ar_classes)
|
|
29
|
+
@truncated_classes = ar_classes
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.truncated_classes
|
|
33
|
+
@truncated_classes ||= []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Default implementation deletes every row in each declared class. Apps
|
|
37
|
+
# override `truncate!` directly if they need custom teardown logic; in
|
|
38
|
+
# that case `truncates` still drives FK-based ordering and the override
|
|
39
|
+
# provides the actual deletion.
|
|
40
|
+
def self.truncate!
|
|
41
|
+
truncated_classes.each(&:delete_all)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Mark the current thread as inside projection-side code for the
|
|
45
|
+
# duration of the block. Acta sets this internally when invoking
|
|
46
|
+
# projection handlers and during `Acta.rebuild!`'s truncate phase, so
|
|
47
|
+
# `acta_managed!` AR models know to allow the writes.
|
|
48
|
+
#
|
|
49
|
+
# Apps can wrap fixture setup, migrations, or one-off backfill
|
|
50
|
+
# operations in `Acta::Projection.applying! { ... }` to bypass the
|
|
51
|
+
# safety net intentionally.
|
|
52
|
+
def self.applying!
|
|
53
|
+
previous = Thread.current[APPLYING_FLAG]
|
|
54
|
+
Thread.current[APPLYING_FLAG] = true
|
|
55
|
+
yield
|
|
56
|
+
ensure
|
|
57
|
+
Thread.current[APPLYING_FLAG] = previous
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.applying?
|
|
61
|
+
Thread.current[APPLYING_FLAG] == true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|