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,61 @@
1
+ require "active_support/concern"
2
+
3
+ module EventRail
4
+ module Internal
5
+ # Mixed into a job the first time it declares a subscription.
6
+ #
7
+ # A subscriber's logical message is the event it is handling, not the job that
8
+ # delivers it. That matters twice: lineage, so a follow-up event records the
9
+ # delivered event as its cause, and identity, so the follow-up derives the same ID on
10
+ # every delivery of that cause rather than a new one per delivery job.
11
+ module SubscriberExecution
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ # Registered after JobContext's, so it runs inside the installed context and the
16
+ # notification carries the lineage the subscriber actually ran with.
17
+ around_perform do |job, block|
18
+ ActiveSupport::Notifications.instrument(
19
+ "perform_subscriber.event_rail",
20
+ Notifications.payload_for(job.arguments.first).merge(job_class: job.class.name)
21
+ ) { block.call }
22
+ end
23
+ end
24
+
25
+ private
26
+ def __event_rail_delivered_event__
27
+ expected = self.class.event_rail_subscriptions
28
+ candidate = arguments.first
29
+
30
+ unless arguments.length == 1 && expected.any? { |event_class| candidate.instance_of?(event_class) }
31
+ raise UnexpectedEventError.new(
32
+ "#{self.class} handles #{expected.map(&:to_s).sort.join(", ")} but received #{describe_arguments}",
33
+ job_class: self.class,
34
+ expected_event_classes: expected,
35
+ received_class: candidate.class
36
+ )
37
+ end
38
+
39
+ # A proposal has no identity, so there would be nothing to install as the
40
+ # logical message or to scope identity on. Rejecting only by class would let one
41
+ # through, because a proposal is an instance of the declared class.
42
+ unless candidate.stamped?
43
+ raise UnexpectedEventError.new(
44
+ "#{self.class} received an unpublished #{candidate.class} proposal, which carries no event identity",
45
+ job_class: self.class,
46
+ expected_event_classes: expected,
47
+ received_class: candidate.class
48
+ )
49
+ end
50
+
51
+ candidate
52
+ end
53
+
54
+ def describe_arguments
55
+ return "no arguments" if arguments.empty?
56
+
57
+ arguments.map { |argument| argument.class.to_s }.join(", ")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,85 @@
1
+ require "active_model/type"
2
+
3
+ module EventRail
4
+ module Internal
5
+ # One normalization for every instant EventRail stores, whether it arrives as
6
+ # metadata or as a declared attribute: an explicit offset is required, an offset
7
+ # outside the valid range is refused rather than quietly dropped, and the result
8
+ # is UTC at microsecond precision.
9
+ module Timestamp
10
+ module_function
11
+
12
+ def cast(value, field: "timestamp")
13
+ normalize(value, field: field, error: InvalidMetadata)
14
+ end
15
+
16
+ UTC_MICROSECOND_FORMAT = "%Y-%m-%dT%H:%M:%S.%6NZ".freeze
17
+
18
+ # The one written spelling of an instant, shared by the portable projection, the
19
+ # job context entry, and the identity encoder, so the same moment never appears
20
+ # in two forms.
21
+ def written(value)
22
+ value.utc.strftime(UTC_MICROSECOND_FORMAT).freeze
23
+ end
24
+
25
+ def normalize(value, field: "timestamp", error: InvalidMetadata)
26
+ return if value.nil?
27
+
28
+ check_offset!(value, field: field, error: error)
29
+
30
+ casted = ActiveModel::Type.lookup(:datetime).cast(value)
31
+ raise error, "#{field} is invalid" unless casted
32
+
33
+ microseconds = (epoch_seconds(casted) * 1_000_000).floor
34
+ Time.at(Rational(microseconds, 1_000_000)).utc.freeze
35
+ rescue Error
36
+ raise
37
+ rescue ArgumentError, TypeError, RangeError => cause
38
+ raise error, "#{field} is invalid: #{cause.message}"
39
+ end
40
+
41
+ # Rails' :datetime cast returns a Time for a string and passes a Time,
42
+ # DateTime, or TimeWithZone through unchanged. Every one of those answers
43
+ # to_r except DateTime, whose only conversion is the to_time path Rails 7.2
44
+ # deprecates, so DateTime is reduced through its own formatted epoch instead.
45
+ def epoch_seconds(value)
46
+ case value
47
+ when DateTime then Rational(value.strftime("%s")) + value.sec_fraction
48
+ else value.to_r
49
+ end
50
+ end
51
+ private_class_method :epoch_seconds
52
+
53
+ # Ruby parses an out-of-range offset into a zone string with no usable offset,
54
+ # and Rails then treats the value as UTC. That silently records a different
55
+ # instant than the caller wrote, so the zone-without-offset case is refused.
56
+ def check_offset!(value, field:, error:)
57
+ case value
58
+ when Time, DateTime
59
+ nil
60
+ when String
61
+ parsed = begin
62
+ Date._parse(value)
63
+ rescue ArgumentError, TypeError
64
+ raise error, "#{field} is invalid"
65
+ end
66
+
67
+ if parsed[:offset].nil?
68
+ if parsed[:zone]
69
+ raise error, "#{field} has an offset outside the valid range: #{parsed[:zone].inspect}"
70
+ end
71
+
72
+ raise error, "#{field} must include an explicit UTC offset"
73
+ end
74
+ when Date
75
+ raise error, "#{field} must include a time of day and an explicit UTC offset"
76
+ else
77
+ unless value.respond_to?(:time_zone) && !value.time_zone.nil?
78
+ raise error, "#{field} must include an explicit UTC offset"
79
+ end
80
+ end
81
+ end
82
+ private_class_method :check_offset!
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,33 @@
1
+ module EventRail
2
+ module Internal
3
+ # Detects an open application database transaction without depending on Active
4
+ # Record.
5
+ #
6
+ # `ActiveRecord.after_all_transactions_commit` runs its block synchronously when no
7
+ # transaction is open and defers it to commit when one is, so calling it with a
8
+ # block that records whether it ran answers the question using only Active Record's
9
+ # public API -- no connection pool, no internal transaction manager, and nothing at
10
+ # all when Active Record is absent.
11
+ module Transaction
12
+ module_function
13
+
14
+ def check!
15
+ return unless open?
16
+
17
+ raise TransactionalPublicationError
18
+ end
19
+
20
+ def open?
21
+ return false unless defined?(::ActiveRecord) && ::ActiveRecord.respond_to?(:after_all_transactions_commit)
22
+
23
+ deferred = true
24
+ ::ActiveRecord.after_all_transactions_commit { deferred = false }
25
+ deferred
26
+ rescue StandardError
27
+ # Active Record is loaded but not usable -- no connection configured, for
28
+ # instance. Publication is not the place to turn that into a failure.
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,441 @@
1
+ require "active_model/type"
2
+ require "bigdecimal"
3
+ require "date"
4
+ require "json"
5
+
6
+ module EventRail
7
+ module Internal
8
+ module Types
9
+ # Every attribute type owns the translation between its cast value and its
10
+ # written form, in both directions, under Active Model's own names. The written
11
+ # form is always a JSON primitive: Active Job does not recurse into a custom
12
+ # serializer's output, so anything else reaches the queue adapter raw.
13
+ #
14
+ # The walk is type-directed rather than shape-guessing. `deserialize` is the
15
+ # trusted wire-input direction the private Active Job serializer and external
16
+ # codecs read through; `cast` stays the strict door for local construction.
17
+
18
+ # Written forms for the scalar types EventRail supports. Each codec states the
19
+ # cast classes it accepts so a delegate that reports a familiar `type` while
20
+ # casting to something else fails during construction rather than writing a
21
+ # value its own reader cannot read back.
22
+ class ScalarCodec
23
+ attr_reader :classes
24
+
25
+ def initialize(classes:, write:, read:)
26
+ @classes = classes.freeze
27
+ @write = write
28
+ @read = read
29
+ freeze
30
+ end
31
+
32
+ def accepts?(value)
33
+ classes.any? { |klass| value.instance_of?(klass) }
34
+ end
35
+
36
+ def write(value)
37
+ @write.call(value)
38
+ end
39
+
40
+ def read(value)
41
+ @read.call(value)
42
+ end
43
+ end
44
+
45
+ SCALAR_CODECS = {
46
+ string: ScalarCodec.new(
47
+ classes: [ String ],
48
+ write: ->(value) { value },
49
+ read: ->(value) { value }
50
+ ),
51
+ integer: ScalarCodec.new(
52
+ classes: [ Integer ],
53
+ write: ->(value) { value },
54
+ read: ->(value) { value }
55
+ ),
56
+ float: ScalarCodec.new(
57
+ classes: [ Float ],
58
+ write: ->(value) { value },
59
+ read: ->(value) { value }
60
+ ),
61
+ boolean: ScalarCodec.new(
62
+ classes: [ TrueClass, FalseClass ],
63
+ write: ->(value) { value },
64
+ read: ->(value) { value }
65
+ ),
66
+ decimal: ScalarCodec.new(
67
+ classes: [ BigDecimal ],
68
+ write: ->(value) { value.to_s("F").freeze },
69
+ read: ->(value) { value }
70
+ ),
71
+ date: ScalarCodec.new(
72
+ classes: [ Date ],
73
+ write: ->(value) { value.iso8601.freeze },
74
+ read: ->(value) { value }
75
+ ),
76
+ datetime: ScalarCodec.new(
77
+ classes: [ Time ],
78
+ write: ->(value) { Timestamp.written(value) },
79
+ read: ->(value) { value }
80
+ )
81
+ }.freeze
82
+
83
+ # Rails' :time is a time-of-day type: casting an instant through it discards
84
+ # the date. A durable fact cannot record that, and :datetime is the type that
85
+ # keeps the whole instant.
86
+ REDIRECTED_TYPES = { time: :datetime }.freeze
87
+
88
+ module Portable
89
+ def serialize(value)
90
+ value
91
+ end
92
+
93
+ def deserialize(value)
94
+ cast(value)
95
+ end
96
+ end
97
+
98
+ class Raw < ActiveModel::Type::Value
99
+ include Portable
100
+
101
+ def cast(value)
102
+ PortableValue.raw(value)
103
+ end
104
+ end
105
+
106
+ # Active Model inherits form-oriented coercion that turns unusable input into a
107
+ # plausible value: "abc" becomes 0, "12abc" becomes 12, true becomes "t", and
108
+ # anything outside a known false list becomes true. A durable fact must not
109
+ # record a fabricated value that no validation can distinguish from a supplied
110
+ # one, so casting is gated on losslessness. The rule is about discarded
111
+ # information rather than trust, so it holds identically for local construction
112
+ # and for wire input: parsing "2026-09-01" into a date stays legal.
113
+ module Lossless
114
+ BOOLEAN_STRINGS = %w[true false t f 1 0 on off].freeze
115
+ BOOLEAN_INTEGERS = [ 0, 1 ].freeze
116
+ ISO_DATE = /\A-?\d{4,}-\d{2}-\d{2}\z/
117
+
118
+ module_function
119
+
120
+ def check!(value, type, path:)
121
+ case type
122
+ when :integer then check_integer!(value, path)
123
+ when :float then check_float!(value, path)
124
+ when :decimal then check_decimal!(value, path)
125
+ when :boolean then check_boolean!(value, path)
126
+ when :string then check_string!(value, path)
127
+ when :date then check_date!(value, path)
128
+ end
129
+ end
130
+
131
+ def check_integer!(value, path)
132
+ case value
133
+ when Integer then nil
134
+ when String then reject!(path, value, "integer") if Integer(value, exception: false).nil?
135
+ when Numeric
136
+ reject!(path, value, "integer") unless value.finite? && value.to_i == value
137
+ else
138
+ reject!(path, value, "integer")
139
+ end
140
+ end
141
+
142
+ def check_float!(value, path)
143
+ case value
144
+ when Numeric then nil
145
+ when String then reject!(path, value, "float") if Float(value, exception: false).nil?
146
+ else reject!(path, value, "float")
147
+ end
148
+ end
149
+
150
+ def check_decimal!(value, path)
151
+ case value
152
+ when Numeric then nil
153
+ when String then reject!(path, value, "decimal") if BigDecimal(value, exception: false).nil?
154
+ else reject!(path, value, "decimal")
155
+ end
156
+ end
157
+
158
+ def check_boolean!(value, path)
159
+ case value
160
+ when true, false then nil
161
+ when String then reject!(path, value, "boolean") unless BOOLEAN_STRINGS.include?(value.downcase)
162
+ when Integer then reject!(path, value, "boolean") unless BOOLEAN_INTEGERS.include?(value)
163
+ else reject!(path, value, "boolean")
164
+ end
165
+ end
166
+
167
+ # Rails renders any object into a string attribute, so a boolean becomes "t"
168
+ # and an integer becomes "5". Neither is recoverable, and neither is what the
169
+ # caller meant to record.
170
+ def check_string!(value, path)
171
+ reject!(path, value, "string") unless value.is_a?(String)
172
+ end
173
+
174
+ # Casting an instant to a date discards its time of day, and which date it
175
+ # discards to depends on an offset the caller may not have stated. A date
176
+ # attribute therefore takes a date, spelled as a Date or as a complete ISO
177
+ # date, and refuses anything carrying a time.
178
+ def check_date!(value, path)
179
+ return if value.instance_of?(Date)
180
+ return if value.is_a?(String) && value.match?(ISO_DATE)
181
+
182
+ if value.is_a?(String) || value.is_a?(Time) || value.is_a?(DateTime)
183
+ raise CastingError,
184
+ "#{path} cannot represent #{value.inspect} as a date without discarding its time of day; " \
185
+ "supply a Date or a YYYY-MM-DD string"
186
+ end
187
+
188
+ reject!(path, value, "date")
189
+ end
190
+
191
+ def reject!(path, value, label)
192
+ raise CastingError, "#{path} cannot represent #{value.inspect} as #{label} without discarding information"
193
+ end
194
+ end
195
+
196
+ class Scalar < ActiveModel::Type::Value
197
+ attr_reader :delegate
198
+
199
+ def initialize(delegate)
200
+ @delegate = delegate
201
+ @codec = SCALAR_CODECS[delegate.type]
202
+ freeze
203
+ end
204
+
205
+ def cast(value)
206
+ return if value.nil?
207
+
208
+ PortableValue.reject_record!(value)
209
+ Lossless.check!(value, type, path: "value")
210
+
211
+ casted = if type == :datetime
212
+ Timestamp.normalize(value, field: "value", error: CastingError)
213
+ else
214
+ @delegate.cast(value)
215
+ end
216
+
217
+ if casted.nil?
218
+ raise CastingError, "value cannot represent #{value.inspect} as #{type} without discarding information"
219
+ end
220
+ return custom_value(casted) unless @codec
221
+
222
+ unless @codec.accepts?(casted)
223
+ raise CastingError, "value for #{type} cast to unsupported #{casted.class}"
224
+ end
225
+
226
+ PortableValue.typed(casted)
227
+ end
228
+
229
+ def serialize(value)
230
+ return if value.nil?
231
+
232
+ @codec ? @codec.write(value) : @delegate.serialize(value)
233
+ end
234
+
235
+ # Every built-in written form is also a form `cast` accepts losslessly, so the
236
+ # strict door doubles as the trusted one and there is no second parser to keep
237
+ # in step with the writer. A custom type reads its own written form.
238
+ def deserialize(value)
239
+ return if value.nil?
240
+
241
+ return custom_value(@delegate.deserialize(value)) unless @codec
242
+
243
+ cast(@codec.read(value))
244
+ end
245
+
246
+ def type
247
+ @delegate.type
248
+ end
249
+
250
+ private
251
+ # A type that supplies its own written form may cast to its own value
252
+ # object. Only the written form has to be portable, so the cast value is
253
+ # frozen and otherwise left alone; deep immutability of a custom value
254
+ # object belongs to the type that defines it.
255
+ def custom_value(value)
256
+ return if value.nil?
257
+
258
+ PortableValue.reject_record!(value)
259
+ value.frozen? ? value : value.freeze
260
+ end
261
+ end
262
+
263
+ class NestedData < ActiveModel::Type::Value
264
+ attr_reader :data_class
265
+
266
+ def initialize(data_class)
267
+ @data_class = data_class
268
+ freeze
269
+ end
270
+
271
+ # Nested Ruby class names never cross a boundary: the record's own portable
272
+ # projection already is its written form.
273
+ def serialize(value)
274
+ value&.data
275
+ end
276
+
277
+ # Trusted, so a field the local class does not declare is preserved rather
278
+ # than rejected. Without this, adding an optional nested attribute without a
279
+ # version bump would break every worker that has not yet deployed it.
280
+ def deserialize(value)
281
+ return if value.nil?
282
+
283
+ unless value.is_a?(Hash)
284
+ raise CastingError, "nested #{data_class} must be reconstructed from a hash"
285
+ end
286
+
287
+ data_class.send(:__event_rail_reconstruct__, value)
288
+ end
289
+
290
+ def cast(value)
291
+ return if value.nil?
292
+ return value if value.instance_of?(data_class)
293
+
294
+ unless value.is_a?(Hash)
295
+ raise CastingError, "nested #{data_class} must be constructed from a hash"
296
+ end
297
+
298
+ data_class.new(value)
299
+ end
300
+
301
+ def type
302
+ :event_rail_data
303
+ end
304
+ end
305
+
306
+ class ArrayOf < ActiveModel::Type::Value
307
+ attr_reader :item_type
308
+
309
+ def initialize(item_type)
310
+ @item_type = item_type
311
+ freeze
312
+ end
313
+
314
+ def serialize(value)
315
+ return if value.nil?
316
+
317
+ value.map { |item| item_type.serialize(item) }.freeze
318
+ end
319
+
320
+ def deserialize(value)
321
+ return if value.nil?
322
+
323
+ each_item(value, "reconstructed") { |item| item_type.deserialize(item) }
324
+ end
325
+
326
+ def cast(value)
327
+ return if value.nil?
328
+
329
+ each_item(value, "constructed") { |item| item_type.cast(item) }
330
+ end
331
+
332
+ def type
333
+ :event_rail_array
334
+ end
335
+
336
+ private
337
+ def each_item(value, verb)
338
+ unless value.is_a?(Array)
339
+ raise CastingError, "array attribute must be #{verb} from an array"
340
+ end
341
+
342
+ value.each_with_index.map do |item, index|
343
+ yield item
344
+ rescue Error => error
345
+ contextual = if error.respond_to?(:validation_errors)
346
+ error.class.new(
347
+ "array item #{index}: #{error.message}",
348
+ validation_errors: error.validation_errors
349
+ )
350
+ else
351
+ error.class.new("array item #{index}: #{error.message}")
352
+ end
353
+
354
+ raise contextual, cause: error
355
+ end.freeze
356
+ end
357
+ end
358
+
359
+ module_function
360
+
361
+ # Active Model already keeps a per-class memoized map of attribute name to
362
+ # type, invalidated on redeclaration, so the resolved type is the only thing
363
+ # worth returning: it is the whole definition.
364
+ def resolve(cast_type, array:, options:)
365
+ type = if cast_type.nil?
366
+ Raw.new
367
+ elsif cast_type.is_a?(Class) && cast_type < EventRail::Data
368
+ NestedData.new(cast_type)
369
+ else
370
+ Scalar.new(resolve_delegate(cast_type, options))
371
+ end
372
+
373
+ array ? ArrayOf.new(type) : type
374
+ rescue ArgumentError => error
375
+ raise DeclarationError, error.message
376
+ end
377
+
378
+ def resolve_delegate(cast_type, options)
379
+ delegate = if cast_type.is_a?(ActiveModel::Type::Value)
380
+ cast_type
381
+ else
382
+ if (replacement = REDIRECTED_TYPES[cast_type])
383
+ raise DeclarationError,
384
+ "attribute type #{cast_type.inspect} discards the date portion of an instant; use #{replacement.inspect}"
385
+ end
386
+
387
+ ActiveModel::Type.lookup(cast_type, **options)
388
+ end
389
+
390
+ verify_portable!(delegate)
391
+ delegate
392
+ end
393
+ private_class_method :resolve_delegate
394
+
395
+ # A type is either one EventRail supplies a written form for, or one that
396
+ # supplies its own and proves it here. Proving it at declaration is the point:
397
+ # the alternative is a job that enqueues fine in a unit test and is rejected by
398
+ # the production adapter.
399
+ def verify_portable!(delegate)
400
+ return if SCALAR_CODECS.key?(delegate.type) && !delegate.is_a?(EventRail::PortableType)
401
+
402
+ unless delegate.is_a?(EventRail::PortableType)
403
+ raise DeclarationError,
404
+ "attribute type #{delegate.class} reports #{delegate.type.inspect}, which EventRail has no written " \
405
+ "form for; supported types are #{SCALAR_CODECS.keys.sort.join(", ")}, or include " \
406
+ "EventRail::PortableType to supply your own"
407
+ end
408
+
409
+ examples = delegate.portable_examples
410
+ unless examples.is_a?(Array) && !examples.empty?
411
+ raise DeclarationError, "#{delegate.class}#portable_examples must return at least one cast value"
412
+ end
413
+
414
+ examples.each { |example| verify_example!(delegate, example) }
415
+ end
416
+
417
+ def verify_example!(delegate, example)
418
+ written = delegate.serialize(example)
419
+ unless PortableValue.json_primitive?(written)
420
+ raise DeclarationError,
421
+ "#{delegate.class}#serialize returned #{written.class} for #{example.inspect}, which is not a JSON " \
422
+ "primitive, array, or string-keyed hash"
423
+ end
424
+
425
+ decoded = JSON.parse(JSON.generate([ written ])).first
426
+ restored = delegate.deserialize(decoded)
427
+ unless restored == example
428
+ raise DeclarationError,
429
+ "#{delegate.class} does not round-trip #{example.inspect}: its written form reconstructs " \
430
+ "#{restored.inspect}"
431
+ end
432
+ rescue DeclarationError
433
+ raise
434
+ rescue StandardError => cause
435
+ raise DeclarationError,
436
+ "#{delegate.class} raised #{cause.class} writing or reading #{example.inspect}: #{cause.message}"
437
+ end
438
+ private_class_method :verify_example!
439
+ end
440
+ end
441
+ end