event_rail 0.1.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/CHANGELOG.md +47 -0
- data/CONTRIBUTING.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +364 -0
- data/SECURITY.md +3 -0
- data/lib/event_rail/contract.rb +44 -0
- data/lib/event_rail/current.rb +95 -0
- data/lib/event_rail/data.rb +4 -0
- data/lib/event_rail/envelope.rb +123 -0
- data/lib/event_rail/errors.rb +196 -0
- data/lib/event_rail/event.rb +258 -0
- data/lib/event_rail/internal/attribute_record.rb +272 -0
- data/lib/event_rail/internal/context.rb +32 -0
- data/lib/event_rail/internal/contract_index.rb +28 -0
- data/lib/event_rail/internal/event_serializer.rb +148 -0
- data/lib/event_rail/internal/execution.rb +86 -0
- data/lib/event_rail/internal/extensions.rb +69 -0
- data/lib/event_rail/internal/identity.rb +90 -0
- data/lib/event_rail/internal/notifications.rb +42 -0
- data/lib/event_rail/internal/portable_value.rb +108 -0
- data/lib/event_rail/internal/registry.rb +224 -0
- data/lib/event_rail/internal/stamping.rb +185 -0
- data/lib/event_rail/internal/subscriber_execution.rb +61 -0
- data/lib/event_rail/internal/timestamp.rb +85 -0
- data/lib/event_rail/internal/transaction.rb +33 -0
- data/lib/event_rail/internal/types.rb +441 -0
- data/lib/event_rail/job_context.rb +124 -0
- data/lib/event_rail/limits.rb +31 -0
- data/lib/event_rail/metadata.rb +94 -0
- data/lib/event_rail/portable_type.rb +35 -0
- data/lib/event_rail/publication.rb +33 -0
- data/lib/event_rail/publish.rb +88 -0
- data/lib/event_rail/railtie.rb +15 -0
- data/lib/event_rail/subscriptions.rb +59 -0
- data/lib/event_rail/version.rb +3 -0
- data/lib/event_rail.rb +52 -0
- metadata +184 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
# The format-neutral boundary an application's own codec reads and writes.
|
|
3
|
+
#
|
|
4
|
+
# envelope = EventRail::Envelope.of(published.event)
|
|
5
|
+
# payload = MyJsonCodec.encode(envelope) # the application owns the bytes
|
|
6
|
+
#
|
|
7
|
+
# EventRail produces no bytes and mandates no format. It exposes the logical fields --
|
|
8
|
+
# contract, metadata, and the event's portable data projection -- and stops there,
|
|
9
|
+
# because canonical bytes, a universal hash, and a content type are decisions that
|
|
10
|
+
# belong to whoever owns the transport. There is deliberately no `to_h` that could
|
|
11
|
+
# become a de facto wire format.
|
|
12
|
+
#
|
|
13
|
+
# `data` is the portable projection, so a codec never receives a `Date`, a
|
|
14
|
+
# `BigDecimal`, or a nested Ruby object it would have to decide how to canonicalize.
|
|
15
|
+
# Every value is a JSON primitive, array, or string-keyed hash, including fields a
|
|
16
|
+
# newer producer added that the local class does not declare.
|
|
17
|
+
class Envelope
|
|
18
|
+
attr_reader :contract, :metadata, :data
|
|
19
|
+
|
|
20
|
+
def self.of(event)
|
|
21
|
+
unless event.is_a?(Event)
|
|
22
|
+
raise InvalidEnvelope, "#{event.inspect} is not an EventRail::Event"
|
|
23
|
+
end
|
|
24
|
+
unless event.stamped?
|
|
25
|
+
raise InvalidEnvelope, "#{event.class} has not been published, so it has no durable metadata to export"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
new(contract: Contract.of(event.class), metadata: event.metadata, data: event.data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(contract:, metadata:, data:)
|
|
32
|
+
unless contract.is_a?(Contract)
|
|
33
|
+
raise InvalidEnvelope, "contract must be an EventRail::Contract; got #{contract.inspect}"
|
|
34
|
+
end
|
|
35
|
+
unless metadata.is_a?(Metadata) && metadata.complete?
|
|
36
|
+
raise InvalidEnvelope, "metadata must be complete EventRail::Metadata; got #{metadata.inspect}"
|
|
37
|
+
end
|
|
38
|
+
unless data.is_a?(Hash) && data.keys.all? { |key| key.is_a?(String) }
|
|
39
|
+
raise InvalidEnvelope, "data must be a string-keyed hash"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@contract = contract
|
|
43
|
+
@metadata = metadata
|
|
44
|
+
@data = data.freeze
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def event_type
|
|
49
|
+
contract.event_type
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def version
|
|
53
|
+
contract.version
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def id
|
|
57
|
+
metadata.id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def source
|
|
61
|
+
metadata.source
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def occurred_at
|
|
65
|
+
metadata.occurred_at
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def correlation_id
|
|
69
|
+
metadata.correlation_id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def causation_id
|
|
73
|
+
metadata.causation_id
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extensions
|
|
77
|
+
metadata.extensions
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The only public trusted-reconstruction entry point, and it takes the class
|
|
81
|
+
# explicitly.
|
|
82
|
+
#
|
|
83
|
+
# V1 defines no resolver protocol and never constantizes anything from the
|
|
84
|
+
# envelope: a type name arriving over a network must not be able to name a Ruby
|
|
85
|
+
# constant. An application maps a contract to a class with its own allowlist before
|
|
86
|
+
# calling this:
|
|
87
|
+
#
|
|
88
|
+
# ALLOWED = { [ "orders.order_placed", 1 ] => Orders::OrderPlaced }.freeze
|
|
89
|
+
# event_class = ALLOWED.fetch([ envelope.event_type, envelope.version ])
|
|
90
|
+
# envelope.to_event(event_class)
|
|
91
|
+
#
|
|
92
|
+
# The internal queue registry deliberately does not authorize external input: it
|
|
93
|
+
# exists so a worker can read its own organization's queue, which is a different
|
|
94
|
+
# trust question from accepting a message off a shared bus.
|
|
95
|
+
def to_event(event_class)
|
|
96
|
+
unless event_class.is_a?(Class) && event_class < Event
|
|
97
|
+
raise InvalidEnvelope, "#{event_class.inspect} is not an EventRail::Event class"
|
|
98
|
+
end
|
|
99
|
+
unless event_class.event_type == event_type && event_class.version == version
|
|
100
|
+
raise InvalidEnvelope,
|
|
101
|
+
"#{event_class} declares #{Contract.of(event_class)} but this envelope carries #{contract}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
event_class.send(:__reconstruct__, data: data, metadata: metadata)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ==(other)
|
|
108
|
+
other.instance_of?(self.class) && other.contract == contract &&
|
|
109
|
+
other.metadata == metadata && other.data == data
|
|
110
|
+
end
|
|
111
|
+
alias_method :eql?, :==
|
|
112
|
+
|
|
113
|
+
def hash
|
|
114
|
+
[ self.class, contract, metadata, data ].hash
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Contract and identity only: an envelope carries domain data, and a diagnostic
|
|
118
|
+
# string is not the place for it.
|
|
119
|
+
def inspect
|
|
120
|
+
"#<EventRail::Envelope #{contract} id=#{id.inspect} source=#{source.inspect}>"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
# Every error EventRail raises descends from this, so an application can rescue
|
|
3
|
+
# the library without naming its internals. Each subclass exposes stable
|
|
4
|
+
# diagnostic fields only -- contracts, identifiers, and application classes --
|
|
5
|
+
# never a registry, serializer, job instance, or other internal object, and each
|
|
6
|
+
# preserves the cause it wrapped so the original backtrace stays reachable.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Something about a class body is wrong, so it is wrong for every instance: a
|
|
11
|
+
# reserved or shadowing attribute name, a callable default, an attribute type with
|
|
12
|
+
# no written form, an invalid subscription.
|
|
13
|
+
class DeclarationError < Error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A value cannot become the declared type without discarding information, or is not
|
|
17
|
+
# portable at all.
|
|
18
|
+
class CastingError < Error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class InvalidData < Error
|
|
22
|
+
attr_reader :validation_errors
|
|
23
|
+
|
|
24
|
+
def initialize(message = "nested data is invalid", validation_errors: {})
|
|
25
|
+
@validation_errors = validation_errors.freeze
|
|
26
|
+
super(message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class InvalidEvent < Error
|
|
31
|
+
attr_reader :validation_errors
|
|
32
|
+
|
|
33
|
+
def initialize(message = "event is invalid", validation_errors: {})
|
|
34
|
+
@validation_errors = validation_errors.freeze
|
|
35
|
+
super(message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class InvalidMetadata < Error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class InvalidContract < Error
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class DuplicateContractError < InvalidContract
|
|
46
|
+
attr_reader :event_type, :version, :event_classes
|
|
47
|
+
|
|
48
|
+
def initialize(event_type:, version:, event_classes:)
|
|
49
|
+
@event_type = event_type
|
|
50
|
+
@version = version
|
|
51
|
+
@event_classes = event_classes.freeze
|
|
52
|
+
names = event_classes.map { |event_class| event_class.name || event_class.inspect }
|
|
53
|
+
super("duplicate event contract #{event_type.inspect} version #{version}: #{names.join(", ")}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A logical context cannot be established: a nested scope tried to replace lineage
|
|
58
|
+
# it inherited, or an identifier or extension is out of bounds.
|
|
59
|
+
class InvalidContext < Error
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An external envelope cannot be trusted into a typed event.
|
|
63
|
+
class InvalidEnvelope < Error
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Publication ran before the application finished preparing, so the subscriber
|
|
67
|
+
# snapshot does not exist yet. Deliberately distinct from a prepared application
|
|
68
|
+
# whose event happens to have no subscribers.
|
|
69
|
+
class NotReadyError < Error
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A subscriber was handed an argument it cannot treat as its declared event.
|
|
73
|
+
class UnexpectedEventError < Error
|
|
74
|
+
attr_reader :job_class, :expected_event_classes, :received_class
|
|
75
|
+
|
|
76
|
+
def initialize(message, job_class:, expected_event_classes:, received_class:)
|
|
77
|
+
@job_class = job_class
|
|
78
|
+
@expected_event_classes = expected_event_classes.freeze
|
|
79
|
+
@received_class = received_class
|
|
80
|
+
super(message)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class SerializationError < Error
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The representation names a format this release does not read. Raised before any
|
|
88
|
+
# subscriber code runs, and distinct from an event schema version problem: the
|
|
89
|
+
# format version governs the representation's structure, not the event's contract.
|
|
90
|
+
class UnsupportedFormatError < SerializationError
|
|
91
|
+
attr_reader :format_version, :supported_format_versions
|
|
92
|
+
|
|
93
|
+
def initialize(format_version:, supported_format_versions:)
|
|
94
|
+
@format_version = format_version
|
|
95
|
+
@supported_format_versions = supported_format_versions.freeze
|
|
96
|
+
super(
|
|
97
|
+
"unsupported EventRail serialization format #{format_version.inspect}; " \
|
|
98
|
+
"this release reads #{supported_format_versions.join(", ")}"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class UnknownEventTypeError < SerializationError
|
|
104
|
+
attr_reader :event_type, :version
|
|
105
|
+
|
|
106
|
+
def initialize(event_type:, version:)
|
|
107
|
+
@event_type = event_type
|
|
108
|
+
@version = version
|
|
109
|
+
super("no registered event class for #{event_type.inspect} version #{version.inspect}")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class UnsupportedEventVersionError < SerializationError
|
|
114
|
+
attr_reader :event_type, :version, :supported_versions
|
|
115
|
+
|
|
116
|
+
def initialize(event_type:, version:, supported_versions:)
|
|
117
|
+
@event_type = event_type
|
|
118
|
+
@version = version
|
|
119
|
+
@supported_versions = supported_versions.freeze
|
|
120
|
+
super(
|
|
121
|
+
"event #{event_type.inspect} version #{version.inspect} is not registered; " \
|
|
122
|
+
"registered versions are #{supported_versions.sort.join(", ")}"
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class PublicationError < Error
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The same logical fact was published twice in one execution after the first
|
|
131
|
+
# publication succeeded.
|
|
132
|
+
class DuplicatePublicationError < PublicationError
|
|
133
|
+
attr_reader :event_type, :version, :event_id
|
|
134
|
+
|
|
135
|
+
def initialize(event_type:, version:, event_id:)
|
|
136
|
+
@event_type = event_type
|
|
137
|
+
@version = version
|
|
138
|
+
@event_id = event_id
|
|
139
|
+
super(
|
|
140
|
+
"#{event_type.inspect} version #{version} with this logical identity was already published " \
|
|
141
|
+
"as #{event_id.inspect} in this execution"
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A retry resolved to a recorded failed publication whose fact differs from the one
|
|
147
|
+
# now being published. Neither choice is safe -- fanning out the recorded payload
|
|
148
|
+
# discards the new data, and reusing the recorded ID for new data lies to every
|
|
149
|
+
# consumer that deduplicates on it -- so publication refuses instead.
|
|
150
|
+
class RetryPayloadMismatchError < PublicationError
|
|
151
|
+
attr_reader :event_type, :version, :event_id, :differing_fields
|
|
152
|
+
|
|
153
|
+
def initialize(event_type:, version:, event_id:, differing_fields:)
|
|
154
|
+
@event_type = event_type
|
|
155
|
+
@version = version
|
|
156
|
+
@event_id = event_id
|
|
157
|
+
@differing_fields = differing_fields.freeze
|
|
158
|
+
super(
|
|
159
|
+
"retrying publication of #{event_type.inspect} version #{version} as #{event_id.inspect} " \
|
|
160
|
+
"but #{differing_fields.sort.join(", ")} differ from the recorded event"
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Publication happened inside an open application database transaction. Both queue
|
|
166
|
+
# deferral settings are wrong there, in opposite directions: a deferred enqueue
|
|
167
|
+
# cannot report its own failure, and an immediate one announces a fact a rollback
|
|
168
|
+
# then contradicts.
|
|
169
|
+
class TransactionalPublicationError < PublicationError
|
|
170
|
+
def initialize(message = nil)
|
|
171
|
+
super(
|
|
172
|
+
message ||
|
|
173
|
+
"cannot publish inside an open database transaction; publish after the transaction commits"
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Fanout could not complete. The accepted and skipped lists are what an operator
|
|
179
|
+
# needs to know which subscribers may already have work queued, because publication
|
|
180
|
+
# is at-least-once and the retry will enqueue all of them again.
|
|
181
|
+
class EnqueueError < PublicationError
|
|
182
|
+
attr_reader :event, :accepted_subscribers, :skipped_subscribers, :failed_subscriber
|
|
183
|
+
|
|
184
|
+
def initialize(event:, accepted_subscribers:, skipped_subscribers:, failed_subscriber:, message: nil)
|
|
185
|
+
@event = event
|
|
186
|
+
@accepted_subscribers = accepted_subscribers.freeze
|
|
187
|
+
@skipped_subscribers = skipped_subscribers.freeze
|
|
188
|
+
@failed_subscriber = failed_subscriber
|
|
189
|
+
super(
|
|
190
|
+
message ||
|
|
191
|
+
"could not enqueue #{failed_subscriber} for #{event.event_type.inspect} version #{event.version} " \
|
|
192
|
+
"(#{event.id}); #{accepted_subscribers.length} accepted, #{skipped_subscribers.length} skipped"
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
module EventRail
|
|
2
|
+
class Event < Internal::AttributeRecord
|
|
3
|
+
VALUE_NOT_GIVEN = Object.new.freeze
|
|
4
|
+
|
|
5
|
+
# Metadata an application may supply, but only as an explicit keyword: reaching
|
|
6
|
+
# it through attribute data is how forged lineage would arrive from a params hash.
|
|
7
|
+
KEYWORD_METADATA_NAMES = %w[extensions occurred_at].freeze
|
|
8
|
+
|
|
9
|
+
# Metadata an application may never supply. These are derived during publication
|
|
10
|
+
# or arrive through validated reconstruction.
|
|
11
|
+
DERIVED_METADATA_NAMES = %w[causation_id correlation_id id source].freeze
|
|
12
|
+
|
|
13
|
+
RESERVED_ATTRIBUTE_NAMES = %w[
|
|
14
|
+
attributes
|
|
15
|
+
contract
|
|
16
|
+
correlation_id
|
|
17
|
+
causation_id
|
|
18
|
+
data
|
|
19
|
+
default_source
|
|
20
|
+
errors
|
|
21
|
+
event_type
|
|
22
|
+
extensions
|
|
23
|
+
id
|
|
24
|
+
identity_by
|
|
25
|
+
metadata
|
|
26
|
+
occurred_at
|
|
27
|
+
payload
|
|
28
|
+
source
|
|
29
|
+
stamped?
|
|
30
|
+
type
|
|
31
|
+
valid?
|
|
32
|
+
version
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def event_type(value = VALUE_NOT_GIVEN)
|
|
37
|
+
return @event_type if value.equal?(VALUE_NOT_GIVEN)
|
|
38
|
+
|
|
39
|
+
unless value.is_a?(String) && !value.empty? && value.valid_encoding?
|
|
40
|
+
raise DeclarationError, "event_type must be a non-empty valid string"
|
|
41
|
+
end
|
|
42
|
+
if value.bytesize > Limits::MAX_EVENT_TYPE_BYTES
|
|
43
|
+
raise DeclarationError, "event_type exceeds #{Limits::MAX_EVENT_TYPE_BYTES} bytes"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@event_type = value.dup.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def version(value = VALUE_NOT_GIVEN)
|
|
50
|
+
return @event_version if value.equal?(VALUE_NOT_GIVEN)
|
|
51
|
+
|
|
52
|
+
unless value.is_a?(Integer) && value.positive?
|
|
53
|
+
raise DeclarationError, "version must be a positive integer"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@event_version = value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def default_source(value = VALUE_NOT_GIVEN)
|
|
60
|
+
if value.equal?(VALUE_NOT_GIVEN)
|
|
61
|
+
return @default_source if instance_variable_defined?(:@default_source)
|
|
62
|
+
return superclass.default_source if superclass.respond_to?(:default_source)
|
|
63
|
+
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Metadata.validate_source!(value)
|
|
68
|
+
@default_source = value.dup.freeze
|
|
69
|
+
rescue InvalidMetadata => error
|
|
70
|
+
raise DeclarationError, error.message
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def identity_by(*attribute_names)
|
|
74
|
+
return (@identity_attributes || []).dup.freeze if attribute_names.empty?
|
|
75
|
+
|
|
76
|
+
names = attribute_names.map(&:to_s)
|
|
77
|
+
if names.empty? || names.any?(&:empty?) || names.uniq.length != names.length
|
|
78
|
+
raise DeclarationError, "identity_by requires unique non-empty attribute names"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@identity_attributes = names.map(&:freeze).freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A class that declares either half of the contract is meant to be published and
|
|
85
|
+
# must declare both. One that declares neither is an application's own abstract
|
|
86
|
+
# base -- `class ApplicationEvent < EventRail::Event` -- and is not a registrable
|
|
87
|
+
# contract, so discovery skips it rather than failing preparation over it.
|
|
88
|
+
def concrete?
|
|
89
|
+
!event_type.nil? || !version.nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_definition!
|
|
93
|
+
unless event_type && version
|
|
94
|
+
raise InvalidContract, "#{self} must explicitly declare event_type and version"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
identity_by.each do |attribute_name|
|
|
98
|
+
unless attribute_names.include?(attribute_name)
|
|
99
|
+
raise InvalidContract, "#{self} identity attribute #{attribute_name.inspect} is not declared"
|
|
100
|
+
end
|
|
101
|
+
unless attribute_types[attribute_name].is_a?(Internal::Types::Scalar)
|
|
102
|
+
raise InvalidContract, "#{self} identity attribute #{attribute_name.inspect} must be scalar"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def contract_key
|
|
110
|
+
validate_definition!
|
|
111
|
+
[ event_type, version ].freeze
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def reserved_attribute_names
|
|
115
|
+
(super + RESERVED_ATTRIBUTE_NAMES).uniq.freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def record_error_class
|
|
119
|
+
InvalidEvent
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
# Not public API: reconstruction of a trusted representation belongs to the
|
|
124
|
+
# private queue serializer and to validated envelope reconstruction, which
|
|
125
|
+
# supply metadata they have already checked. An application that could call
|
|
126
|
+
# this could install any lineage it liked.
|
|
127
|
+
def __reconstruct__(data:, metadata:)
|
|
128
|
+
validate_definition!
|
|
129
|
+
unless metadata.is_a?(Metadata) && metadata.complete?
|
|
130
|
+
raise InvalidMetadata, "trusted reconstruction requires complete metadata"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
__event_rail_reconstruct__(data, state: { :@metadata => metadata })
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
attr_reader :metadata
|
|
138
|
+
|
|
139
|
+
def initialize(attributes = nil, occurred_at: nil, extensions: {}, **payload)
|
|
140
|
+
self.class.validate_definition!
|
|
141
|
+
|
|
142
|
+
# Trusted reconstruction installs complete metadata before initialize runs.
|
|
143
|
+
@metadata ||= Metadata.proposed(occurred_at: occurred_at, extensions: extensions)
|
|
144
|
+
super(attributes, **payload)
|
|
145
|
+
|
|
146
|
+
validate_local_identity! unless @metadata.complete?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def event_type
|
|
150
|
+
self.class.event_type
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def version
|
|
154
|
+
self.class.version
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def id
|
|
158
|
+
metadata.id
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def source
|
|
162
|
+
metadata.source
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def occurred_at
|
|
166
|
+
metadata.occurred_at
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def correlation_id
|
|
170
|
+
metadata.correlation_id
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def causation_id
|
|
174
|
+
metadata.causation_id
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def extensions
|
|
178
|
+
metadata.extensions
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def stamped?
|
|
182
|
+
metadata.complete?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Metadata participates, so a proposal and the stamped event copied from it are
|
|
186
|
+
# different values: one is a fact with an identity and the other is a request to
|
|
187
|
+
# record one.
|
|
188
|
+
def ==(other)
|
|
189
|
+
super && other.metadata == metadata
|
|
190
|
+
end
|
|
191
|
+
alias_method :eql?, :==
|
|
192
|
+
|
|
193
|
+
def hash
|
|
194
|
+
[ self.class, data, metadata ].hash
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Contract and metadata only: domain payload and extensions stay out of
|
|
198
|
+
# diagnostics, including Active Job argument logging.
|
|
199
|
+
def inspect
|
|
200
|
+
"#<#{self.class.name || self.class.inspect} type=#{event_type.inspect} version=#{version.inspect} " \
|
|
201
|
+
"id=#{id.inspect} source=#{source.inspect} occurred_at=#{occurred_at.inspect}>"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
# Not public API: stamping installs the identity and lineage that make an event
|
|
206
|
+
# a published fact, and publication is the only thing entitled to do that.
|
|
207
|
+
def __stamp__(id:, source: nil, occurred_at: nil, correlation_id:, causation_id: nil, extensions: nil)
|
|
208
|
+
resolved_source = source || self.class.default_source
|
|
209
|
+
raise InvalidMetadata, "source is required to stamp #{self.class}" unless resolved_source
|
|
210
|
+
|
|
211
|
+
stamped_metadata = Metadata.complete(
|
|
212
|
+
id: id,
|
|
213
|
+
source: resolved_source,
|
|
214
|
+
occurred_at: occurred_at || metadata.occurred_at,
|
|
215
|
+
correlation_id: correlation_id,
|
|
216
|
+
causation_id: causation_id,
|
|
217
|
+
extensions: extensions || metadata.extensions
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# A metadata-only copy. Reconstructing through the validating constructor
|
|
221
|
+
# would re-cast and re-validate payload this instance already canonicalized,
|
|
222
|
+
# and would rebuild every nested data object, for no change to the payload.
|
|
223
|
+
self.class.send(:__event_rail_copy__, self, state: { :@metadata => stamped_metadata })
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A declared identity attribute that is nil cannot produce a canonical value, so
|
|
227
|
+
# the event can never be published. The local constructor is the strict door and
|
|
228
|
+
# the only place whose backtrace points at the code that left the field empty.
|
|
229
|
+
# Trusted reconstruction stays permissive: an external event of this contract may
|
|
230
|
+
# legitimately omit a field, and it arrives with an identity already assigned.
|
|
231
|
+
def validate_local_identity!
|
|
232
|
+
missing = self.class.identity_by.select { |name| public_send(name).nil? }
|
|
233
|
+
return if missing.empty?
|
|
234
|
+
|
|
235
|
+
raise InvalidEvent,
|
|
236
|
+
"#{self.class} declares #{missing.sort.join(", ")} as logical identity, so it cannot be nil"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def unknown_attributes_message(unknown)
|
|
240
|
+
names = unknown.keys
|
|
241
|
+
keyword_only = names & KEYWORD_METADATA_NAMES
|
|
242
|
+
derived = names & DERIVED_METADATA_NAMES
|
|
243
|
+
return super if keyword_only.empty? && derived.empty?
|
|
244
|
+
|
|
245
|
+
parts = []
|
|
246
|
+
unless keyword_only.empty?
|
|
247
|
+
parts << "#{keyword_only.sort.join(", ")} must be supplied as a keyword argument, not as attribute data"
|
|
248
|
+
end
|
|
249
|
+
unless derived.empty?
|
|
250
|
+
parts << "#{derived.sort.join(", ")} cannot be set locally; event metadata is derived during " \
|
|
251
|
+
"publication or supplied through validated reconstruction"
|
|
252
|
+
end
|
|
253
|
+
remaining = names - keyword_only - derived
|
|
254
|
+
parts << "unknown attributes: #{remaining.sort.join(", ")}" unless remaining.empty?
|
|
255
|
+
parts.join("; ")
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|