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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CONTRIBUTING.md +14 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +364 -0
  6. data/SECURITY.md +3 -0
  7. data/lib/event_rail/contract.rb +44 -0
  8. data/lib/event_rail/current.rb +95 -0
  9. data/lib/event_rail/data.rb +4 -0
  10. data/lib/event_rail/envelope.rb +123 -0
  11. data/lib/event_rail/errors.rb +196 -0
  12. data/lib/event_rail/event.rb +258 -0
  13. data/lib/event_rail/internal/attribute_record.rb +272 -0
  14. data/lib/event_rail/internal/context.rb +32 -0
  15. data/lib/event_rail/internal/contract_index.rb +28 -0
  16. data/lib/event_rail/internal/event_serializer.rb +148 -0
  17. data/lib/event_rail/internal/execution.rb +86 -0
  18. data/lib/event_rail/internal/extensions.rb +69 -0
  19. data/lib/event_rail/internal/identity.rb +90 -0
  20. data/lib/event_rail/internal/notifications.rb +42 -0
  21. data/lib/event_rail/internal/portable_value.rb +108 -0
  22. data/lib/event_rail/internal/registry.rb +224 -0
  23. data/lib/event_rail/internal/stamping.rb +185 -0
  24. data/lib/event_rail/internal/subscriber_execution.rb +61 -0
  25. data/lib/event_rail/internal/timestamp.rb +85 -0
  26. data/lib/event_rail/internal/transaction.rb +33 -0
  27. data/lib/event_rail/internal/types.rb +441 -0
  28. data/lib/event_rail/job_context.rb +124 -0
  29. data/lib/event_rail/limits.rb +31 -0
  30. data/lib/event_rail/metadata.rb +94 -0
  31. data/lib/event_rail/portable_type.rb +35 -0
  32. data/lib/event_rail/publication.rb +33 -0
  33. data/lib/event_rail/publish.rb +88 -0
  34. data/lib/event_rail/railtie.rb +15 -0
  35. data/lib/event_rail/subscriptions.rb +59 -0
  36. data/lib/event_rail/version.rb +3 -0
  37. data/lib/event_rail.rb +52 -0
  38. metadata +184 -0
@@ -0,0 +1,272 @@
1
+ require "active_model/attributes"
2
+ require "active_model/validations"
3
+
4
+ module EventRail
5
+ module Internal
6
+ class AttributeRecord
7
+ # Deliberately not ActiveModel::Model: that bundle also brings Conversion and
8
+ # Access, whose to_model, to_key, to_param, to_partial_path, persisted?, slice
9
+ # and values_at are view and form concerns. They are meaningless on an
10
+ # immutable historical fact and would become public API at 1.0.
11
+ include ActiveModel::Attributes
12
+ include ActiveModel::AttributeAssignment
13
+ include ActiveModel::Validations
14
+
15
+ DEFAULT_NOT_GIVEN = Object.new.freeze
16
+
17
+ class << self
18
+ def attribute(name, cast_type = nil, default: DEFAULT_NOT_GIVEN, array: false, **options)
19
+ attribute_name = name.to_s
20
+ validate_attribute_name!(attribute_name)
21
+
22
+ type = Types.resolve(cast_type, array: array, options: options)
23
+
24
+ if default.equal?(DEFAULT_NOT_GIVEN)
25
+ super(attribute_name, type)
26
+ else
27
+ unless PortableValue.literal_default?(default)
28
+ raise DeclarationError, "#{self} attribute #{attribute_name.inspect} cannot use a callable default"
29
+ end
30
+
31
+ super(attribute_name, type, default: type.cast(default))
32
+ end
33
+ end
34
+
35
+ def reserved_attribute_names
36
+ %w[attributes data errors valid?].freeze
37
+ end
38
+
39
+ def record_error_class
40
+ InvalidData
41
+ end
42
+
43
+ private
44
+ def validate_attribute_name!(name)
45
+ if reserved_attribute_names.include?(name)
46
+ raise DeclarationError, "#{self} cannot declare reserved attribute #{name.inspect}"
47
+ end
48
+
49
+ # Redeclaring an existing attribute is legitimate; its readers are ours.
50
+ return if attribute_types.key?(name)
51
+
52
+ shadowed = [ name, "#{name}=" ].find { |candidate| shadows_behavior?(candidate) }
53
+ return unless shadowed
54
+
55
+ raise DeclarationError,
56
+ "#{self} cannot declare attribute #{name.inspect} because #{shadowed.inspect} is already defined"
57
+ end
58
+
59
+ # A reader can only shadow behavior a caller could have reached, so public
60
+ # and protected methods are the boundary. Ruby defines a large private
61
+ # vocabulary on every object -- format, select, open, p, raise -- and those
62
+ # are ordinary domain words that no reader can shadow, because no caller
63
+ # could have invoked them through the receiver. The record's own private
64
+ # helpers are the exception: those are reached internally, so a reader
65
+ # replacing one would break this class from the inside.
66
+ def shadows_behavior?(name)
67
+ return true if method_defined?(name)
68
+ return false unless private_method_defined?(name)
69
+
70
+ owner = instance_method(name).owner
71
+ owner.name.to_s.start_with?("EventRail")
72
+ end
73
+
74
+ # Untrusted input: casts, validates, canonicalizes and freezes. State the
75
+ # caller already derived, such as reconstructed metadata, is installed
76
+ # before initialize runs, so no sentinel rides on the public signature.
77
+ #
78
+ # Trusted input arrives in written form and is read back through each
79
+ # declared type's `deserialize` before assignment, which is what lets a
80
+ # nested record preserve fields this version does not declare. Casting then
81
+ # sees values it already accepts, so the strict door stays strict without a
82
+ # second, laxer parser beside it.
83
+ def __event_rail_build__(declared, unknown: {}, state: {}, trusted: false)
84
+ values = trusted ? deserialize_declared(declared) : declared
85
+
86
+ record = allocate
87
+ state.each { |ivar, value| record.instance_variable_set(ivar, value) }
88
+ record.instance_variable_set(
89
+ :@unknown_attributes, PortableValue.raw(unknown, path: "unknown attributes")
90
+ )
91
+ record.send(:initialize, values)
92
+ record
93
+ end
94
+
95
+ # Partition is by declaration, so a field a newer producer added stays
96
+ # opaque payload without a reader instead of failing the reconstruction.
97
+ def __event_rail_reconstruct__(portable, state: {})
98
+ unless portable.is_a?(Hash) && portable.keys.all? { |key| key.is_a?(String) }
99
+ raise record_error_class, "trusted #{self} data must be a string-keyed hash"
100
+ end
101
+
102
+ known_names = attribute_names
103
+ declared, unknown = portable.partition { |name, _value| known_names.include?(name) }.map(&:to_h)
104
+ __event_rail_build__(declared, unknown: unknown, state: state, trusted: true)
105
+ end
106
+
107
+ def deserialize_declared(declared)
108
+ types = attribute_types
109
+
110
+ declared.each_with_object({}) do |(name, value), result|
111
+ result[name] = begin
112
+ types[name].deserialize(value)
113
+ rescue Error => error
114
+ raise error.class.new("attribute #{name.inspect}: #{error.message}"), cause: error
115
+ end
116
+ end
117
+ end
118
+
119
+ # Trusted in-process copy: the source is an instance of this class whose
120
+ # payload is already cast, validated, canonicalized and deeply frozen.
121
+ # Varying state the payload does not depend on cannot invalidate it, so
122
+ # nothing needs recomputing.
123
+ def __event_rail_copy__(source, state: {})
124
+ copy = allocate
125
+ source.instance_variables.each do |ivar|
126
+ copy.instance_variable_set(ivar, source.instance_variable_get(ivar))
127
+ end
128
+ state.each { |ivar, value| copy.instance_variable_set(ivar, value) }
129
+ copy.errors
130
+ copy.freeze
131
+ end
132
+ end
133
+
134
+ def initialize(attributes = nil, **keyword_attributes)
135
+ input = normalize_input(attributes, keyword_attributes)
136
+ declared, local_unknown = partition_attributes(input)
137
+
138
+ unless local_unknown.empty?
139
+ raise record_error_class, unknown_attributes_message(local_unknown)
140
+ end
141
+
142
+ # ActiveModel::Attributes#initialize takes no arguments; assignment is
143
+ # AttributeAssignment's job, which ActiveModel::API used to chain for us.
144
+ super()
145
+ assign_attributes(declared) unless declared.empty?
146
+ self.class.attribute_names.each { |name| public_send(name) }
147
+
148
+ @unknown_attributes ||= PortableValue.raw({}, path: "unknown attributes")
149
+ validate_record!
150
+ @data = build_data
151
+ @attributes.freeze
152
+ freeze
153
+ rescue Error
154
+ raise
155
+ rescue ActiveModel::UnknownAttributeError, ArgumentError => error
156
+ raise record_error_class, error.message
157
+ end
158
+
159
+ # The written projection of the whole payload: JSON primitives, arrays, and
160
+ # string-keyed hashes, including fields a newer producer added that this version
161
+ # does not declare. This is the single form the private queue representation and
162
+ # the public envelope both read, and it is built once per instance rather than
163
+ # once per serialization.
164
+ def data
165
+ @data
166
+ end
167
+
168
+ # Two records of one class that carry the same payload are the same value. The
169
+ # comparison is over the written projection rather than the cast attributes so
170
+ # that a record reconstructed from a queue matches the one it was written from,
171
+ # and so that unknown preserved fields participate.
172
+ def ==(other)
173
+ other.instance_of?(self.class) && other.data == data
174
+ end
175
+ alias_method :eql?, :==
176
+
177
+ def hash
178
+ [ self.class, data ].hash
179
+ end
180
+
181
+ # An immutable value has no meaningful copy, and Active Model's own
182
+ # initialize_dup deep-dups into an unfrozen attribute set while clearing
183
+ # errors, which would otherwise hand back a mutable record whose readers
184
+ # disagree with its payload view.
185
+ def dup
186
+ frozen? ? self : super
187
+ end
188
+
189
+ def clone(freeze: nil)
190
+ frozen? ? self : super
191
+ end
192
+
193
+ # Validations already ran once, during construction. Active Model would
194
+ # re-run them here, which raises on a frozen record on Rails 7.2 and can
195
+ # report a published fact as invalid on later versions when a validation
196
+ # depends on external state.
197
+ def valid?(context = nil)
198
+ frozen? ? true : super
199
+ end
200
+
201
+ # Object#inspect prints every instance variable, and Active Job logs each
202
+ # argument's inspect by default, so an unmanaged representation publishes
203
+ # domain payload into ordinary application logs.
204
+ def inspect
205
+ "#<#{self.class.name || self.class.inspect}>"
206
+ end
207
+
208
+ private
209
+ def attribute_method?(attribute_name)
210
+ self.class.attribute_names.include?(attribute_name)
211
+ end
212
+
213
+ def normalize_input(attributes, keyword_attributes)
214
+ unless attributes.nil? || attributes.is_a?(Hash)
215
+ raise record_error_class, "attributes must be supplied as a hash or keywords"
216
+ end
217
+
218
+ combined = (attributes || {}).merge(keyword_attributes) do |key|
219
+ raise record_error_class, "attribute #{key.inspect} was supplied more than once"
220
+ end
221
+
222
+ combined.each_with_object({}) do |(key, value), result|
223
+ unless key.is_a?(String) || key.is_a?(Symbol)
224
+ raise record_error_class, "attribute names must be strings or symbols"
225
+ end
226
+
227
+ normalized = key.to_s
228
+ if result.key?(normalized)
229
+ raise record_error_class, "attribute #{normalized.inspect} was supplied more than once"
230
+ end
231
+
232
+ result[normalized] = value
233
+ end
234
+ end
235
+
236
+ def unknown_attributes_message(unknown)
237
+ "unknown attributes: #{unknown.keys.sort.join(", ")}"
238
+ end
239
+
240
+ def partition_attributes(input)
241
+ known_names = self.class.attribute_names
242
+ input.partition { |name, _value| known_names.include?(name) }.map(&:to_h)
243
+ end
244
+
245
+ def validate_record!
246
+ return if valid?
247
+
248
+ error_hash = errors.to_hash(true).transform_values { |messages| messages.map(&:dup).freeze }.freeze
249
+ raise record_error_class.new(record_validation_message, validation_errors: error_hash)
250
+ end
251
+
252
+ # Declared names in declaration order, then preserved unknown fields, so the
253
+ # written form of one logical payload is byte-identical between processes.
254
+ def build_data
255
+ types = self.class.attribute_types
256
+ declared = self.class.attribute_names.to_h do |name|
257
+ [ -name, types[name].serialize(public_send(name)) ]
258
+ end
259
+
260
+ declared.merge(@unknown_attributes).freeze
261
+ end
262
+
263
+ def record_error_class
264
+ self.class.record_error_class
265
+ end
266
+
267
+ def record_validation_message
268
+ "#{self.class} is invalid: #{errors.full_messages.join(", ")}"
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,32 @@
1
+ module EventRail
2
+ module Internal
3
+ # Installs lineage for the duration of a block and restores exactly what was
4
+ # there before.
5
+ #
6
+ # The restore is manual rather than `CurrentAttributes#set`, because the values
7
+ # have to survive nested in-process execution. The Rails executor resets current
8
+ # attributes around `ActiveJob::Base.execute`, but a job performed inside another
9
+ # -- the test adapter, the inline adapter, any `perform_now` -- never passes
10
+ # through the executor, so without an explicit restore the inner job's lineage
11
+ # would still be installed when the outer job resumes.
12
+ module Context
13
+ module_function
14
+
15
+ FIELDS = [ :message_id, :correlation_id, :causation_id, :originated_at, :extensions ].freeze
16
+
17
+ def establish(message_id:, correlation_id:, causation_id:, originated_at:, extensions:)
18
+ previous = FIELDS.to_h { |field| [ field, Current.public_send(field) ] }
19
+
20
+ Current.message_id = message_id
21
+ Current.correlation_id = correlation_id
22
+ Current.causation_id = causation_id
23
+ Current.originated_at = originated_at
24
+ Current.extensions = extensions
25
+
26
+ yield
27
+ ensure
28
+ previous.each { |field, value| Current.public_send(:"#{field}=", value) }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,28 @@
1
+ module EventRail
2
+ module Internal
3
+ class ContractIndex
4
+ def self.build(event_classes)
5
+ index = {}
6
+
7
+ event_classes.each do |event_class|
8
+ unless event_class.is_a?(Class) && event_class < EventRail::Event
9
+ raise InvalidContract, "#{event_class.inspect} is not an EventRail::Event class"
10
+ end
11
+
12
+ key = event_class.contract_key
13
+ if index.key?(key)
14
+ raise DuplicateContractError.new(
15
+ event_type: key.first,
16
+ version: key.last,
17
+ event_classes: [ index.fetch(key), event_class ]
18
+ )
19
+ end
20
+
21
+ index[key] = event_class
22
+ end
23
+
24
+ index.freeze
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,148 @@
1
+ module EventRail
2
+ module Internal
3
+ # The private Active Job representation of an event.
4
+ #
5
+ # Private means EventRail owns it entirely: its shape is not public API, no
6
+ # application should read or write it, and it may change under the staging
7
+ # discipline below. The public boundary is `EventRail::Envelope`.
8
+ #
9
+ # Every value written is a JSON primitive, array, or string-keyed hash. That is not
10
+ # a stylistic choice: `ObjectSerializer#serialize` is `@template.merge(hash)` and
11
+ # `ActiveJob::Arguments.serialize` does not recurse into the result, so a `Date`,
12
+ # `Time`, or `BigDecimal` left here would reach the queue adapter raw -- where a
13
+ # JSON-native adapter rejects the job and a JSON column stringifies it and loses
14
+ # precision. The per-type `serialize` contract is what guarantees it, and
15
+ # `event.data` is already the whole payload in that form.
16
+ #
17
+ # FORMAT_VERSION governs the structure of this hash and nothing else. It is
18
+ # deliberately independent of an event's schema version, and it does not govern the
19
+ # canonical encoding of individual values -- that encoding is shared with the public
20
+ # envelope and evolves as a public compatibility contract, not as a private format
21
+ # bump.
22
+ #
23
+ # Evolving it is a staged, read-before-write deployment: a release that reads both
24
+ # the old and the new format ships everywhere first, a later release starts writing
25
+ # the new one, and the old reader is removed only once every queued old-format
26
+ # message is drained or expired.
27
+ class EventSerializer < ActiveJob::Serializers::ObjectSerializer
28
+ FORMAT_VERSION = 1
29
+ SUPPORTED_FORMAT_VERSIONS = [ FORMAT_VERSION ].freeze
30
+
31
+ FORMAT_KEY = "format"
32
+ TYPE_KEY = "event_type"
33
+ VERSION_KEY = "event_version"
34
+ METADATA_KEY = "metadata"
35
+ DATA_KEY = "data"
36
+
37
+ ID = "id"
38
+ SOURCE = "source"
39
+ OCCURRED_AT = "occurred_at"
40
+ CORRELATION_ID = "correlation_id"
41
+ CAUSATION_ID = "causation_id"
42
+ EXTENSIONS = "extensions"
43
+
44
+ # Every Event subclass, so applications register nothing.
45
+ def klass
46
+ EventRail::Event
47
+ end
48
+
49
+ # Read through methods rather than the constants directly, so a staged release can
50
+ # subclass this to widen what it reads before anything writes the newer form. The
51
+ # staging fixtures rely on that, and so would a real format migration.
52
+ def format_version
53
+ FORMAT_VERSION
54
+ end
55
+
56
+ def supported_format_versions
57
+ SUPPORTED_FORMAT_VERSIONS
58
+ end
59
+
60
+ def serialize(event)
61
+ unless event.stamped?
62
+ raise InvalidEvent,
63
+ "#{event.class} has not been published, so it cannot be enqueued; pass the event EventRail.publish " \
64
+ "returned rather than the proposal"
65
+ end
66
+
67
+ super(
68
+ FORMAT_KEY => format_version,
69
+ TYPE_KEY => event.event_type,
70
+ VERSION_KEY => event.version,
71
+ METADATA_KEY => {
72
+ ID => event.id,
73
+ SOURCE => event.source,
74
+ OCCURRED_AT => Timestamp.written(event.occurred_at),
75
+ CORRELATION_ID => event.correlation_id,
76
+ CAUSATION_ID => event.causation_id,
77
+ EXTENSIONS => event.extensions
78
+ },
79
+ DATA_KEY => event.data
80
+ )
81
+ end
82
+
83
+ def deserialize(hash)
84
+ written_format = hash[FORMAT_KEY]
85
+ unless supported_format_versions.include?(written_format)
86
+ raise UnsupportedFormatError.new(
87
+ format_version: written_format, supported_format_versions: supported_format_versions
88
+ )
89
+ end
90
+
91
+ event_type = hash[TYPE_KEY]
92
+ version = hash[VERSION_KEY]
93
+ event_class = resolve!(event_type, version)
94
+
95
+ payload = Notifications.payload_for_representation(hash).merge(format_version: written_format)
96
+
97
+ ActiveSupport::Notifications.instrument("deserialize.event_rail", payload) do
98
+ event_class.send(:__reconstruct__, data: read_data(hash), metadata: read_metadata(hash))
99
+ end
100
+ end
101
+
102
+ private
103
+ # Resolved through the prepared registry, never by constantizing the type name.
104
+ # An unknown type and an unregistered version are different operational problems:
105
+ # the first says this worker does not know the contract at all, the second says it
106
+ # knows it at other versions and a producer is ahead of or behind this deploy.
107
+ def resolve!(event_type, version)
108
+ snapshot = Registry.snapshot
109
+ event_class = snapshot.event_class_for(event_type, version)
110
+ return event_class if event_class
111
+
112
+ known_versions = snapshot.versions_of(event_type)
113
+ if known_versions.empty?
114
+ raise UnknownEventTypeError.new(event_type: event_type, version: version)
115
+ end
116
+
117
+ raise UnsupportedEventVersionError.new(
118
+ event_type: event_type, version: version, supported_versions: known_versions
119
+ )
120
+ end
121
+
122
+ def read_metadata(hash)
123
+ metadata = hash[METADATA_KEY]
124
+ unless metadata.is_a?(Hash)
125
+ raise SerializationError, "malformed EventRail representation: metadata must be a hash"
126
+ end
127
+
128
+ Metadata.complete(
129
+ id: metadata[ID],
130
+ source: metadata[SOURCE],
131
+ occurred_at: metadata[OCCURRED_AT],
132
+ correlation_id: metadata[CORRELATION_ID],
133
+ causation_id: metadata[CAUSATION_ID],
134
+ extensions: metadata[EXTENSIONS] || {}
135
+ )
136
+ end
137
+
138
+ def read_data(hash)
139
+ data = hash[DATA_KEY]
140
+ unless data.is_a?(Hash)
141
+ raise SerializationError, "malformed EventRail representation: data must be a hash"
142
+ end
143
+
144
+ data
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,86 @@
1
+ require "active_support/isolated_execution_state"
2
+
3
+ module EventRail
4
+ module Internal
5
+ # One publishing execution: a job attempt, or an application boundary block.
6
+ #
7
+ # Publication state belongs to exactly one attempt. It cannot live in
8
+ # `ActiveSupport::CurrentAttributes`, because the Rails executor resets those
9
+ # around `ActiveJob::Base.execute` and nested in-process execution gets no reset,
10
+ # so a subscriber performed inside its publisher -- the test adapter, the inline
11
+ # adapter, any `perform_now` -- would share the publisher's map and could have a
12
+ # legitimate publication rejected as a duplicate in test while succeeding in
13
+ # production.
14
+ #
15
+ # It cannot live in `ActiveSupport::ExecutionContext[:job]` either, which is the
16
+ # mechanism this design originally named. `ActiveJob::Execution#_perform_job`
17
+ # assigns that key and never restores it, so after a nested `perform_now` returns
18
+ # it still points at the inner job: the outer job's next publication would read
19
+ # the inner job's state and derive identity from the inner job's scope. The stack
20
+ # here is pushed and popped by EventRail itself, in an ensure, so a nested
21
+ # execution restores its parent exactly.
22
+ class Execution
23
+ STACK_KEY = :event_rail_executions
24
+
25
+ class << self
26
+ def stack
27
+ ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
28
+ end
29
+
30
+ def current
31
+ stack.last
32
+ end
33
+
34
+ def push(execution)
35
+ stack.push(execution)
36
+ execution
37
+ end
38
+
39
+ def pop(execution)
40
+ stack.pop if stack.last.equal?(execution)
41
+ end
42
+
43
+ def wrap(execution)
44
+ push(execution)
45
+ yield execution
46
+ ensure
47
+ pop(execution)
48
+ end
49
+ end
50
+
51
+ attr_reader :job_class, :scope, :started_at
52
+
53
+ # `job_class` is nil for a boundary block. That is what separates the two: a
54
+ # boundary supplies an occurrence time and lineage but derives no identity, so
55
+ # an event published there still receives a random ID, and no duplicate check
56
+ # applies outside a job attempt.
57
+ def initialize(job_class: nil, scope: nil, started_at: nil)
58
+ @job_class = job_class
59
+ @scope = scope
60
+ @started_at = started_at
61
+ @publications = {}
62
+ end
63
+
64
+ def derives_identity?
65
+ !job_class.nil? && !scope.nil?
66
+ end
67
+
68
+ def record(key)
69
+ @publications[key]
70
+ end
71
+
72
+ def record!(key, event:, succeeded:)
73
+ @publications[key] = Record.new(event: event, succeeded: succeeded)
74
+ end
75
+
76
+ # What one logical publication looked like the last time this attempt tried it.
77
+ # Retaining the failed case is what lets the same attempt retry complete fanout
78
+ # under the identity it already stamped, instead of manufacturing a second one.
79
+ Record = Struct.new(:event, :succeeded, keyword_init: true) do
80
+ def succeeded?
81
+ succeeded
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,69 @@
1
+ module EventRail
2
+ module Internal
3
+ # Extensions are durable baggage, so their bounds are the same wherever they are
4
+ # installed: on an event, on a logical context, or merged from both. One
5
+ # implementation keeps the limits from drifting apart between those doors, and the
6
+ # caller supplies the error class so the message names the thing being built.
7
+ module Extensions
8
+ module_function
9
+
10
+ EMPTY = {}.freeze
11
+
12
+ def validate!(value, error:)
13
+ return EMPTY if value.nil? || (value.is_a?(Hash) && value.empty?)
14
+
15
+ unless value.is_a?(Hash)
16
+ raise error, "extensions must be a hash of string keys and values"
17
+ end
18
+ if value.length > Limits::MAX_EXTENSION_ENTRIES
19
+ raise error, "extensions exceed #{Limits::MAX_EXTENSION_ENTRIES} entries"
20
+ end
21
+
22
+ total_bytes = 0
23
+ result = value.each_with_object({}) do |(key, item), output|
24
+ unless key.is_a?(String) && item.is_a?(String)
25
+ raise error, "extension keys and values must be strings"
26
+ end
27
+ if Limits::RESERVED_EXTENSION_KEYS.include?(key) || key.start_with?("eventrail.")
28
+ raise error, "extension key #{key.inspect} is reserved"
29
+ end
30
+ if key.empty? || !key.valid_encoding? || key.bytesize > Limits::MAX_EXTENSION_KEY_BYTES
31
+ raise error, "extension key #{key.inspect} is invalid or too long"
32
+ end
33
+ if !item.valid_encoding? || item.bytesize > Limits::MAX_EXTENSION_VALUE_BYTES
34
+ raise error, "extension value for #{key.inspect} is invalid or too long"
35
+ end
36
+
37
+ total_bytes += key.bytesize + item.bytesize
38
+ output[key.dup.freeze] = item.dup.freeze
39
+ end
40
+
41
+ if total_bytes > Limits::MAX_EXTENSIONS_BYTES
42
+ raise error, "extensions exceed #{Limits::MAX_EXTENSIONS_BYTES} encoded bytes"
43
+ end
44
+
45
+ result.freeze
46
+ end
47
+
48
+ # Repeating a key with the same value is how a nested scope says "still true".
49
+ # Repeating it with a different value is an attempt to rewrite baggage an
50
+ # ancestor installed, which would make the same key mean different things at
51
+ # different depths of one flow.
52
+ def merge!(inherited, added, error:)
53
+ return inherited if added.nil? || added.empty?
54
+
55
+ validated = validate!(added, error: error)
56
+ return validated if inherited.nil? || inherited.empty?
57
+
58
+ conflicts = validated.filter_map do |key, value|
59
+ key if inherited.key?(key) && inherited.fetch(key) != value
60
+ end
61
+ unless conflicts.empty?
62
+ raise error, "extension #{conflicts.sort.join(", ")} already has a different value in this context"
63
+ end
64
+
65
+ validate!(inherited.merge(validated), error: error)
66
+ end
67
+ end
68
+ end
69
+ end