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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/current_attributes"
5
+
6
+ module Acta
7
+ class Current < ActiveSupport::CurrentAttributes
8
+ attribute :actor
9
+ end
10
+ end
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ class Handler
5
+ def self.on(event_class, &block)
6
+ Acta.subscribe(event_class, self, &block)
7
+ end
8
+ end
9
+ end
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