event_rail 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e59345edd1e6e7570345e9b68df5be4b565e5705051d35f854076a64e8fc08b8
4
- data.tar.gz: d1391a4e11a36c9a81df720b6cd1376e4ddc79a126fab0fa8988db435a4823b4
3
+ metadata.gz: 191255f222c7ea1b8b082d118d7d6c4111c52f822cb320004fbcb0323a07d464
4
+ data.tar.gz: 4082d4d03ca75711cf9077d54d7fa1249e04738e29ce5e575fa4fb336d49389b
5
5
  SHA512:
6
- metadata.gz: a1bc401719f38d3f8fa654c1aba1df05451aaed81f813c7af74bb6c865ce90f35ccf48d579ec982627d973cd9f705e076b546303fd4b9759d5107dd5d99b9025
7
- data.tar.gz: 6200d549898556da58c5d09ebe252373a10d73e4876494850cba5ae6f5603c2d2175b2744801eb02ab8ab57cd8ac9a7c65b7e2c9c34e63fc1906e872636bb8fa
6
+ metadata.gz: 2e0b5b49ad4be65fade9e5e9120aed1bcd1a87fd51ab49288b05a3f380d906af1b146d42568d82a83bee34581bfa268ef512b97c2602f533c5eb32168a64afa6
7
+ data.tar.gz: 20685b5f579f6d68a3742ca1388e4f52714a25165071d38899ba98bbd78b45c0be8173dee1a587c134902c5128dd9c9dc2ee75d4ce4b135d409994bcef71a3e5
data/CHANGELOG.md CHANGED
@@ -13,6 +13,50 @@ here unless a release changes how they behave.
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.2.0] - 2026-09-17
17
+
18
+ ### Added
19
+
20
+ - `EventRail::TestHelper`, loaded by an explicit `require "event_rail/test_helper"` and not
21
+ by requiring the library. `EventRail::TestHelper.declare` opens a window in which event
22
+ contracts and subscriptions may be declared after application preparation has sealed the
23
+ registry, and `with_subscribers` activates a declared fixture subscriber for the duration
24
+ of one block, restoring the previous registry afterwards. Contracts declared in a window
25
+ go live when it closes; subscribers stay dormant until activated, so a fixture cannot fan
26
+ out in a later test that never mentions it.
27
+ - `config.event_rail.roots`, defaulting to `%w[app/events app/jobs]` and appendable, naming
28
+ the directories discovery scans. Each entry is matched as a path suffix against the main
29
+ autoloader's roots, so one entry covers the host application and every engine. A
30
+ configured root beyond the default that is not an autoload root fails preparation with the
31
+ new `EventRail::ConfigurationError`; a default root the application has not created is
32
+ skipped.
33
+
34
+ ### Changed
35
+
36
+ - **A concrete `EventRail::Event` subclass that declares `event_type` or `version` after
37
+ preparation now raises `EventRail::DeclarationError` at the declaration**, the same rule
38
+ subscriptions have always followed. Previously such a class was silently absent from the
39
+ contract index: it published cleanly, serialized cleanly, and failed only when a worker
40
+ tried to reconstruct it, with `UnknownEventTypeError`. Move the class under `app/events`,
41
+ add its root to `config.event_rail.roots`, or -- for a test fixture -- declare it inside
42
+ `EventRail::TestHelper.declare`. Abstract bases and classes never assigned to a constant
43
+ are unaffected.
44
+ - A subscription declared on a class with no name now raises where it is declared. Active
45
+ Job cannot enqueue a job it cannot name, so this was never a working configuration; it was
46
+ previously dropped from the registry without comment.
47
+ - Both late-declaration messages now name the declaration's own file and line, state that
48
+ discovery runs before `eager_load!` so `config.eager_load_paths` alone is never enough,
49
+ and offer both remedies. The advice to load such code from an autoload-once path is gone
50
+ as general guidance: it never applied to reloadable application code.
51
+
52
+ ### Fixed
53
+
54
+ - A declaration arriving between a reload's constant unload and the registry rebuild is no
55
+ longer rejected as late. Rails deletes constants before running prepare callbacks and
56
+ nothing cleared the snapshot in between, so that window was indistinguishable from a
57
+ sealed registry -- reachable by any prepare callback ordered ahead of EventRail's,
58
+ including one belonging to a gem that loads earlier in the Gemfile.
59
+
16
60
  ## [0.1.0] - 2026-09-15
17
61
 
18
62
  ### Added
@@ -43,5 +87,6 @@ here unless a release changes how they behave.
43
87
  - A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
44
88
  casting, context, serialization, and publication failures.
45
89
 
46
- [Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...HEAD
90
+ [Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...HEAD
91
+ [0.2.0]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...v0.2.0
47
92
  [0.1.0]: https://github.com/alexdmtv/event-rail/releases/tag/v0.1.0
data/README.md CHANGED
@@ -20,7 +20,7 @@ EventRail depends on Active Support, Active Model, Active Job, Railties, and Zei
20
20
  gem "event_rail"
21
21
  ```
22
22
 
23
- Then run `bundle install`. There is no initializer to generate and nothing to configure: EventRail initializes itself through a Railtie, and every safety limit is a fixed documented constant.
23
+ Then run `bundle install`. There is no initializer to generate and nothing you have to configure: EventRail initializes itself through a Railtie, every safety limit is a fixed documented constant, and the one option it does expose -- [where it looks for events and subscribers](#where-discovery-looks) -- has a default that most applications never change.
24
24
 
25
25
  ## Job integration
26
26
 
@@ -150,9 +150,27 @@ publication.skipped_subscribers # subscribers whose own enqueue callback declin
150
150
 
151
151
  Subscribers are discovered from the conventional `app/events` and `app/jobs` roots of the host application and every engine, during Rails preparation. There is no registration API, no initializer, and no registry to query.
152
152
 
153
+ ### Where discovery looks
154
+
155
+ ```ruby
156
+ # doc:illustrative
157
+ # config/application.rb
158
+ config.event_rail.roots << "app/subscribers" # default: %w[app/events app/jobs]
159
+ ```
160
+
161
+ Each entry is matched as a path suffix against the autoload roots Rails already has, so one entry covers the host application, every engine, and a packwerk-style `packs/billing/app/jobs` without naming any of them. Appending is additive: the defaults stay in effect.
162
+
163
+ Two things to know before reaching for it. A configured root is eager-loaded during preparation in **every** environment, so naming something broad like `app/models` loads that directory for the application and every engine on each boot and each reload -- moving the file is usually the better fix. And a root you name that is not an autoload root of the application or any engine fails preparation with `EventRail::ConfigurationError`, so a typo is a boot error rather than a directory that silently discovers nothing. A *default* root the application has not created is simply skipped, so a fresh application with no `app/events` directory boots normally.
164
+
153
165
  `subscribes_to` is exact and not inherited: a subclass of a subscriber is a different job and receives nothing. A subscriber must define its own `perform` taking exactly one required positional event parameter, must not have subclasses, and must include `EventRail::JobContext`. Each of those is checked during preparation, so a mistake fails the boot that introduced it rather than the first publication.
154
166
 
155
- A subscriber declared outside those roots must be loaded before preparation finishes, or declaring it raises: EventRail refuses to run with a subscriber it cannot see at boot. A subscriber required from an initializer works, but initializers run before the main autoloader exists, so such a file has to bring its own event class and job base rather than referencing autoloaded constants. Declaring a subscriber after preparation -- from a test file, or from a lazily autoloaded path outside `app/events` and `app/jobs` -- raises for the same reason, so a test that needs a throwaway subscriber should define it in a file under a conventional root of the test application instead.
167
+ A subscriber must also be reachable by name, because Active Job enqueues a job by name. Declaring a subscription on a class that has none raises immediately, so `Foo.const_set(:Bar, Class.new(ApplicationJob) { subscribes_to Baz })` is not supported -- name the class first. The check is on the name the class carries, not on whether a constant resolves to it, so a class with a name nothing resolves to is still dropped from the registry without comment; that is a deliberate limit, not an oversight.
168
+
169
+ One rule covers both halves of discovery: **an event contract or a subscription declared after preparation has sealed the registry raises**, naming the file and line and the two ways to fix it. EventRail refuses to run with a subscriber that would receive nothing, or with an event class a worker could not reconstruct from the queue.
170
+
171
+ Adding the directory to `config.eager_load_paths` is never enough on its own, and the reason is Rails' own ordering: prepare callbacks run *before* `eager_load!`, so a reloadable class outside a discovery root cannot be loaded in time in any environment. Either move the file under a discovery root, or [add its root](#where-discovery-looks).
172
+
173
+ Two exceptions. Code that is not reloadable at all -- a gem, or a file plainly required from an initializer -- ran its declaration before preparation, so it is already registered; such a file has to bring its own event class and job base, because initializers run before the main autoloader exists. And a test suite loads after preparation by definition, which is what [`EventRail::TestHelper`](#tests-that-need-their-own-fixtures) is for.
156
174
 
157
175
  ### At-least-once delivery, and what that means for subscribers
158
176
 
@@ -293,7 +311,63 @@ assert_enqueued_with(job: Docs::OnOrderPlacedJob, args: [ publication.event ])
293
311
  perform_enqueued_jobs
294
312
  ```
295
313
 
296
- `assert_enqueued_with(args:)` works because events are value objects. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper.
314
+ `assert_enqueued_with(args:)` works because events are value objects. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper -- Active Job's helpers are the whole assertion surface.
315
+
316
+ ### Tests that need their own fixtures
317
+
318
+ A test file loads after preparation, so declaring a throwaway event or subscriber in one would raise. `EventRail::TestHelper` is the door for it, and it is opt-in:
319
+
320
+ ```ruby
321
+ # doc:illustrative
322
+ # test/test_helper.rb
323
+ require "event_rail/test_helper"
324
+
325
+ class ActiveSupport::TestCase
326
+ include EventRail::TestHelper
327
+ end
328
+ ```
329
+
330
+ Declare fixtures at file scope, in a window:
331
+
332
+ ```ruby
333
+ EventRail::TestHelper.declare do
334
+ module OrderTests
335
+ class Placed < EventRail::Event
336
+ event_type "docs.order_tests_placed"
337
+ version 1
338
+ default_source "tests"
339
+
340
+ attribute :order_id, :string
341
+ end
342
+
343
+ class AuditJob < ApplicationJob
344
+ subscribes_to Placed
345
+
346
+ def perform(event) = Rails.logger.info(event.order_id)
347
+ end
348
+ end
349
+ end
350
+ ```
351
+
352
+ **A subscriber declared in a window receives nothing until you activate it.** That is deliberate: a fixture that went live on declaration would fan out in every later test in the process, including tests that never mention it. Activate it for one block:
353
+
354
+ ```ruby
355
+ # doc:illustrative
356
+ test "publication fans out to the audit job" do
357
+ with_subscribers(OrderTests::AuditJob) do
358
+ publication = EventRail.publish(OrderTests::Placed.new(order_id: "o-1"))
359
+
360
+ assert_enqueued_with(job: OrderTests::AuditJob, args: [ publication.event ])
361
+ perform_enqueued_jobs
362
+ end
363
+ end
364
+ ```
365
+
366
+ The previous registry is restored when the block exits, including when it raises, and nested activations are additive. Activation applies the same validation preparation does, so an abstract fixture fails there rather than at delivery.
367
+
368
+ Event contracts behave differently from subscribers on purpose: they are registered when the window closes and stay registered, because `assert_enqueued_with` deserializes the job it is comparing and so needs the contract outside any block. A class cannot be unloaded, which has one consequence worth knowing -- **give each fixture a unique `event_type`, and declare it once, at file scope.** Two live classes claiming one type and version fail every later rebuild for the rest of the process, and a declaration inside a test method reopens the same constant and runs the writer again on a sealed registry.
369
+
370
+ Activation replaces a process-wide registry, so it is not safe under `parallelize(with: :threads)`. Process-based parallelisation, the Rails default, is unaffected.
297
371
 
298
372
  ## Notifications
299
373
 
@@ -13,6 +13,12 @@ module EventRail
13
13
  class DeclarationError < Error
14
14
  end
15
15
 
16
+ # An application's own configuration is wrong: a discovery root that is not an autoload
17
+ # root of the application or any engine. Distinct from a declaration fault, which is about
18
+ # a class body, and raised at preparation so it fails the boot that introduced it.
19
+ class ConfigurationError < Error
20
+ end
21
+
16
22
  # A value cannot become the declared type without discarding information, or is not
17
23
  # portable at all.
18
24
  class CastingError < Error
@@ -43,6 +43,12 @@ module EventRail
43
43
  raise DeclarationError, "event_type exceeds #{Limits::MAX_EVENT_TYPE_BYTES} bytes"
44
44
  end
45
45
 
46
+ # Only a change is a declaration. Re-running a class body that sets the same value --
47
+ # a `load` rather than a `require`, a reopened class -- alters no contract, so the
48
+ # index is already correct and rejecting it would be a false positive.
49
+ unless @event_type == value
50
+ Internal::Registry.declare_contract(self, caller_locations(1, 1).first)
51
+ end
46
52
  @event_type = value.dup.freeze
47
53
  end
48
54
 
@@ -53,6 +59,9 @@ module EventRail
53
59
  raise DeclarationError, "version must be a positive integer"
54
60
  end
55
61
 
62
+ unless @event_version == value
63
+ Internal::Registry.declare_contract(self, caller_locations(1, 1).first)
64
+ end
56
65
  @event_version = value
57
66
  end
58
67
 
@@ -12,7 +12,14 @@ module EventRail
12
12
  # to have no subscribers: the first is a boot-order bug and raises, the second is a
13
13
  # legitimate zero-delivery publication.
14
14
  class Registry
15
- CONVENTIONAL_ROOTS = %w[app/events app/jobs].freeze
15
+ # The conventional discovery roots. Matched as a path suffix against the main
16
+ # autoloader's roots, which is what makes one entry cover the host application, every
17
+ # engine, and packwerk-style `packs/*/app/jobs` without naming any of them.
18
+ #
19
+ # An application may append through `config.event_rail.roots`. The default is copied
20
+ # rather than shared, so `<<` works and cannot mutate this constant.
21
+ DEFAULT_ROOTS = %w[app/events app/jobs].freeze
22
+ CONVENTIONAL_ROOTS = DEFAULT_ROOTS
16
23
 
17
24
  Snapshot = Struct.new(:subscribers, :contracts, keyword_init: true) do
18
25
  def subscribers_for(event_class)
@@ -34,23 +41,69 @@ module EventRail
34
41
  @pending = []
35
42
  @snapshot = nil
36
43
  @building = false
44
+ # A test declaration window, opened by EventRail::TestHelper.declare. Distinct from
45
+ # @building: a window routes subscriptions to the dormant fixture set rather than to
46
+ # the live pending list, which is what keeps a fixture from fanning out to every
47
+ # later test in the process.
48
+ @window = false
49
+ @fixtures = []
50
+ # Set from `before_class_unload`, which fires first in a reload cycle, and cleared when
51
+ # preparation finishes. Rails deletes the constants and only then runs prepare
52
+ # callbacks, and nothing clears the snapshot in between -- so without this flag the
53
+ # window between the two is indistinguishable from a sealed registry, and a legal
54
+ # declaration arriving there would be rejected for lateness.
55
+ @reloading = false
56
+ # Subscribers activated for the duration of a block, and the snapshot and fixture set
57
+ # to restore when it exits. A stack, so nested activations are additive.
58
+ @active_fixtures = []
59
+ @activations = []
37
60
 
38
61
  class << self
39
62
  # Appending is idempotent per job class: the macro may be called more than once
40
63
  # in one body, and the declarations themselves live on the job class.
41
- def declare(job_class)
64
+ def declare(job_class, location = nil)
42
65
  @monitor.synchronize do
43
- if @snapshot && !@building
66
+ if @window
67
+ @fixtures << job_class unless @fixtures.include?(job_class)
68
+ next
69
+ end
70
+
71
+ if @snapshot && !@building && !@reloading
44
72
  raise DeclarationError,
45
- "#{job_class} declared a subscription after EventRail finished preparing, so it would receive no " \
46
- "deliveries. Move it under a conventional app/events or app/jobs root, or load it from an " \
47
- "autoload-once path or plain require before application preparation."
73
+ "#{named(job_class)}#{" (#{at(location)})" if location} declared a subscription after EventRail finished " \
74
+ "preparing, so it would receive no deliveries. #{discovery_advice}. A subscriber in a gem that is " \
75
+ "not loaded at boot can be required from an initializer. In a test, define it inside " \
76
+ "EventRail::TestHelper.declare."
48
77
  end
49
78
 
50
79
  @pending << job_class unless @pending.include?(job_class)
51
80
  end
52
81
  end
53
82
 
83
+ # The contract half of the sealing rule, called from the writer form of
84
+ # `EventRail::Event.event_type` and `.version`. A check only: contracts are collected
85
+ # by rescanning `EventRail::Event.descendants` when a snapshot is built, so there is
86
+ # no pending list for events and nothing here to keep reload-safe.
87
+ #
88
+ # Named classes only. An unnamed class can never enter the index -- `build_contracts`
89
+ # selects through `live?`, which resolves the constant the name denotes -- so checking
90
+ # one would reject every inline event definition in a test suite for no guarantee
91
+ # gained.
92
+ def declare_contract(event_class, location = nil)
93
+ return if event_class.name.nil?
94
+
95
+ @monitor.synchronize do
96
+ next unless @snapshot
97
+ next if @building || @reloading || @window
98
+
99
+ raise DeclarationError,
100
+ "#{named(event_class)}#{" (#{at(location)})" if location} declared an event contract after EventRail " \
101
+ "finished preparing, so a worker could not reconstruct it from the queue. #{discovery_advice}. An " \
102
+ "event class in a gem that is not loaded at boot can be required from an initializer. In a test, " \
103
+ "define it inside EventRail::TestHelper.declare."
104
+ end
105
+ end
106
+
54
107
  def snapshot
55
108
  snapshot = @snapshot
56
109
  return snapshot if snapshot
@@ -64,6 +117,17 @@ module EventRail
64
117
  !@snapshot.nil?
65
118
  end
66
119
 
120
+ # Marks the start of a reload cycle. Registered once from a Railtie initializer, never
121
+ # from preparation: a callback registered inside `prepare` would be added again on
122
+ # every reload and accumulate for the life of the process.
123
+ def reloading!
124
+ @monitor.synchronize { @reloading = true }
125
+ end
126
+
127
+ def reloading?
128
+ @reloading
129
+ end
130
+
67
131
  def subscribers_for(event_class)
68
132
  snapshot.subscribers_for(event_class)
69
133
  end
@@ -80,6 +144,11 @@ module EventRail
80
144
  @snapshot = build_snapshot
81
145
  ensure
82
146
  @building = previously_building
147
+ # Cleared here rather than from a reloader callback because the two reload paths
148
+ # differ: a console `reload!` runs prepare twice, the executor path that serves a
149
+ # request runs it once. The end of preparation is the only point correct for
150
+ # both. In `ensure`, so a failed prepare does not leave sealing disabled.
151
+ @reloading = false
83
152
  end
84
153
  end
85
154
  @snapshot
@@ -100,11 +169,90 @@ module EventRail
100
169
  end
101
170
  end
102
171
 
172
+ # Opens a test declaration window. Subscriptions declared inside become dormant
173
+ # fixtures; event contracts declared inside are indexed when the window closes,
174
+ # because `build_contracts` rescans `EventRail::Event.descendants` and a named class
175
+ # stays there for the life of the process.
176
+ #
177
+ # The monitor is held across the yield, as `reopen` does, because a window is opened
178
+ # at file scope before any test runs. `activate` deliberately does not, since it
179
+ # yields to arbitrary test code.
180
+ def declare_fixtures
181
+ @monitor.synchronize do
182
+ unless @activations.empty?
183
+ raise ArgumentError,
184
+ "a declaration window cannot be opened inside with_subscribers, because closing it rebuilds the " \
185
+ "registry and would discard the activation"
186
+ end
187
+
188
+ previously = @window
189
+ @window = true
190
+ begin
191
+ yield
192
+ ensure
193
+ @window = previously
194
+ end
195
+
196
+ # Rebuilding here is what puts window-declared contracts in the index. Nothing
197
+ # is eager-loaded: the window body has already run.
198
+ @snapshot = build_snapshot(extra_subscribers: @active_fixtures) if @snapshot
199
+ end
200
+ end
201
+
202
+ # Activates dormant fixture subscribers for the duration of the block, then restores
203
+ # the previous snapshot. The block runs outside the monitor: a test may spawn a
204
+ # thread that calls `prepare`, and holding the lock across the yield would deadlock
205
+ # it.
206
+ def activate(job_classes)
207
+ restore = nil
208
+
209
+ @monitor.synchronize do
210
+ validate_activation!(job_classes)
211
+
212
+ # Built before anything is committed: `build_snapshot` applies the same
213
+ # validation preparation does, so an abstract or context-less fixture raises
214
+ # here. Assigning first would leave the failed fixture active for every later
215
+ # activation in the process.
216
+ # Deduplicated: activating a fixture that an enclosing block already activated
217
+ # must not register it twice, which would double its fanout -- the same defect
218
+ # `prune_stale_declarations` exists to prevent on the reload path.
219
+ fixtures = (@active_fixtures + job_classes).uniq
220
+ candidate = build_snapshot(extra_subscribers: fixtures)
221
+
222
+ restore = [ @snapshot, @active_fixtures ]
223
+ @snapshot = candidate
224
+ @active_fixtures = fixtures
225
+ @activations.push(restore)
226
+ end
227
+
228
+ begin
229
+ yield
230
+ ensure
231
+ @monitor.synchronize do
232
+ @snapshot, @active_fixtures = restore
233
+ @activations.pop
234
+ end
235
+ end
236
+ end
237
+
238
+ def fixtures
239
+ @fixtures.dup.freeze
240
+ end
241
+
103
242
  def reset!
104
243
  @monitor.synchronize do
105
244
  @pending = []
106
245
  @snapshot = nil
107
246
  @building = false
247
+ @window = false
248
+ @reloading = false
249
+ @active_fixtures = []
250
+ @activations = []
251
+ # @fixtures is deliberately not cleared, for the same reason the pending list is
252
+ # pruned rather than emptied: a fixture is declared once at file scope and its
253
+ # window never runs again, so clearing it would leave every later activation in
254
+ # the process unable to find it. Fixtures are dormant, so keeping them changes
255
+ # no snapshot.
108
256
  end
109
257
  end
110
258
 
@@ -118,8 +266,11 @@ module EventRail
118
266
  loader = Rails.autoloaders.main
119
267
  return unless loader.respond_to?(:eager_load_dir)
120
268
 
269
+ roots = configured_roots
270
+ validate_roots!(roots, loader)
271
+
121
272
  loader.dirs.each do |dir|
122
- next unless CONVENTIONAL_ROOTS.any? { |root| dir.end_with?("/#{root}") }
273
+ next unless roots.any? { |root| dir.end_with?("/#{root}") }
123
274
  next unless Dir.exist?(dir)
124
275
 
125
276
  begin
@@ -132,6 +283,75 @@ module EventRail
132
283
  end
133
284
  end
134
285
 
286
+ # Both late-declaration messages share this. It interpolates the configured roots
287
+ # rather than hardcoding the defaults, and it names the ordering fact that makes the
288
+ # obvious fix wrong: Rails runs prepare callbacks before `eager_load!`, so adding a
289
+ # directory to `eager_load_paths` is always too late.
290
+ def discovery_advice
291
+ # Falls back to the default rather than raising: this builds the text of another
292
+ # error, and a misconfigured roots value has its own error from `validate_roots!`
293
+ # at preparation.
294
+ roots = configured_roots
295
+ roots = DEFAULT_ROOTS.dup unless well_formed_roots?(roots)
296
+ "EventRail discovers #{roots.join(" and ")} in the application and its engines, and discovery runs " \
297
+ "before eager_load!, so adding the directory to eager_load_paths is not enough: move the file under " \
298
+ "#{roots.first}, or add its root to config.event_rail.roots (which eager-loads that directory " \
299
+ "during preparation in every environment)"
300
+ end
301
+
302
+ # `to_s` on a class whose `name` was overridden still reports the anonymous form, and
303
+ # the name is what an adopter searches for.
304
+ def named(klass)
305
+ klass.name || klass.inspect
306
+ end
307
+
308
+ # Relative to the application root, so the message names the file the way an editor
309
+ # does rather than with an absolute path.
310
+ def at(location)
311
+ path = location.path
312
+ root = Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
313
+ path = path.delete_prefix("#{root}/") if root
314
+
315
+ "#{path}:#{location.lineno}"
316
+ end
317
+
318
+ # Copied on every read, so the registry never holds a reference the application can
319
+ # mutate afterwards and repeated reloads cannot accumulate entries.
320
+ def configured_roots
321
+ configured =
322
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
323
+ Rails.application.config.event_rail&.roots
324
+ end
325
+
326
+ (configured || DEFAULT_ROOTS).dup
327
+ end
328
+
329
+ def well_formed_roots?(roots)
330
+ roots.is_a?(Array) && roots.all? { |root| root.is_a?(String) && !root.empty? }
331
+ end
332
+
333
+ # Only entries beyond the default are checked. Rails registers an `app/*` directory
334
+ # with the main loader only when it exists, so a freshly generated application that
335
+ # has not created `app/events` yet has no such root -- validating the defaults would
336
+ # fail its boot before it had written a single event. A missing default is a
337
+ # convention not yet used; a missing entry the application typed is a mistake.
338
+ def validate_roots!(roots, loader)
339
+ unless well_formed_roots?(roots)
340
+ raise ConfigurationError,
341
+ "config.event_rail.roots must be an array of non-empty strings; got #{roots.inspect}"
342
+ end
343
+
344
+ dirs = loader.dirs.map(&:to_s)
345
+
346
+ (roots - DEFAULT_ROOTS).each do |root|
347
+ next if dirs.any? { |dir| dir.end_with?("/#{root}") }
348
+
349
+ raise ConfigurationError,
350
+ "config.event_rail.roots names #{root.inspect}, which is not an autoload root of the application " \
351
+ "or any engine"
352
+ end
353
+ end
354
+
135
355
  # A reload replaces class objects while leaving the previous ones reachable
136
356
  # from this list. An entry survives only if the constant its own name denotes
137
357
  # is still this exact object, which is what distinguishes a live class from a
@@ -156,11 +376,17 @@ module EventRail
156
376
  resolved.equal?(klass)
157
377
  end
158
378
 
159
- def build_snapshot
379
+ # `extra_subscribers` carries fixtures activated for a block. They are not added to
380
+ # the pending list, so the next rebuild without them drops them again.
381
+ def build_snapshot(extra_subscribers: EMPTY_SUBSCRIBERS)
160
382
  contracts = build_contracts
161
383
  subscribers = {}
162
384
 
163
- @pending.each do |job_class|
385
+ # The pending list is filtered here, not only pruned by preparation. A snapshot
386
+ # may be built without a preceding prune -- `activate` does exactly that -- and
387
+ # the list can hold an entry whose macro ran before a later argument raised, so
388
+ # a snapshot must never validate a class the constant no longer denotes.
389
+ (@pending.select { |job_class| live?(job_class) } + extra_subscribers).uniq.each do |job_class|
164
390
  validate_subscriber!(job_class)
165
391
 
166
392
  job_class.event_rail_subscriptions.each do |event_class|
@@ -186,6 +412,30 @@ module EventRail
186
412
  ContractIndex.build(concrete)
187
413
  end
188
414
 
415
+ # These are caller mistakes in a test, not failures of the library's declaration
416
+ # rules, so they raise ArgumentError: an adopter rescuing EventRail::Error should
417
+ # not catch them.
418
+ def validate_activation!(job_classes)
419
+ job_classes.each do |job_class|
420
+ if job_class.name.nil?
421
+ raise ArgumentError,
422
+ "#{job_class.inspect} has no name, and Active Job cannot enqueue a job it cannot name; " \
423
+ "assign the class to a constant"
424
+ end
425
+
426
+ next if @fixtures.include?(job_class)
427
+
428
+ if @pending.include?(job_class)
429
+ raise ArgumentError,
430
+ "#{job_class} is already a live subscriber; with_subscribers is for fixtures declared in a test"
431
+ end
432
+
433
+ raise ArgumentError,
434
+ "#{job_class} was not declared inside EventRail::TestHelper.declare, so with_subscribers cannot " \
435
+ "activate it"
436
+ end
437
+ end
438
+
189
439
  def validate_subscriber!(job_class)
190
440
  unless job_class.instance_methods(false).include?(:perform) ||
191
441
  job_class.private_instance_methods(false).include?(:perform)
@@ -6,10 +6,31 @@ module EventRail
6
6
  # eager_load!, so no reloadable constant outside a conventional root can already be
7
7
  # loaded here -- which is exactly why one declared there raises later instead of
8
8
  # silently receiving nothing.
9
+ # The directories discovery scans, matched as a path suffix against the main
10
+ # autoloader's roots. An application appends to it; the default is a mutable copy, so
11
+ # `config.event_rail.roots << "app/subscribers"` works.
12
+ config.event_rail = ActiveSupport::OrderedOptions.new
13
+ config.event_rail.roots = Internal::Registry::DEFAULT_ROOTS.dup
14
+
9
15
  config.to_prepare do
10
16
  # Unqualified, so lexical lookup reaches the private Internal namespace that a
11
17
  # qualified EventRail::Internal reference would be refused.
12
18
  Internal::Registry.prepare
13
19
  end
20
+
21
+ # A reload deletes the constants and only then runs prepare callbacks, and nothing
22
+ # clears the snapshot in between -- so a declaration arriving in that window would be
23
+ # rejected for lateness even though the imminent prepare would have registered it. That
24
+ # window is reachable by any prepare callback ordered ahead of EventRail's, which is the
25
+ # case for one registered directly on the reloader from an initializer, or belonging to
26
+ # a gem that loads earlier in the Gemfile.
27
+ #
28
+ # An initializer rather than a prepare block: registering the callback from `prepare`
29
+ # would add it again on every reload.
30
+ initializer "event_rail.mark_reload_window" do |app|
31
+ if app.config.reloading_enabled?
32
+ app.reloader.before_class_unload { Internal::Registry.reloading! }
33
+ end
34
+ end
14
35
  end
15
36
  end
@@ -39,7 +39,20 @@ module EventRail
39
39
  own << event_class
40
40
  end
41
41
 
42
- Internal::Registry.declare(self)
42
+ # Checked here rather than during preparation, and after the arguments above rather
43
+ # than before them. Active Job cannot enqueue a job it cannot name, so an anonymous
44
+ # subscriber is never a working configuration -- but preparation is the wrong place to
45
+ # say so: the macro has already recorded the class by then, and rejecting it there
46
+ # would make every later rebuild in the process fail, including in unrelated tests.
47
+ # After the argument checks, because a bad argument on an anonymous class deserves the
48
+ # specific error rather than this one.
49
+ if name.nil?
50
+ raise DeclarationError,
51
+ "#{inspect} declared a subscription but has no name, and Active Job cannot enqueue a job it cannot " \
52
+ "name; assign the class to a constant"
53
+ end
54
+
55
+ Internal::Registry.declare(self, caller_locations(1, 1).first)
43
56
  include Internal::SubscriberExecution unless include?(Internal::SubscriberExecution)
44
57
 
45
58
  event_rail_subscriptions
@@ -0,0 +1,74 @@
1
+ require "event_rail"
2
+
3
+ module EventRail
4
+ # Test support, loaded only by an explicit `require "event_rail/test_helper"`. Requiring
5
+ # the library does not load it, and requiring it does not by itself weaken the rule that
6
+ # a declaration arriving after preparation is an error.
7
+ #
8
+ # # test/test_helper.rb
9
+ # require "event_rail/test_helper"
10
+ #
11
+ # class ActiveSupport::TestCase
12
+ # include EventRail::TestHelper
13
+ # end
14
+ #
15
+ # Including the module adds `with_subscribers` and nothing else. `declare` stays a module
16
+ # method rather than becoming an instance method on every test case, because the name is
17
+ # general enough to collide.
18
+ module TestHelper
19
+ # Opens a window in which event contracts and subscriptions may be declared even though
20
+ # preparation has already sealed the registry. Fixtures go at file scope, once per
21
+ # process:
22
+ #
23
+ # EventRail::TestHelper.declare do
24
+ # module OrderTests
25
+ # class Placed < EventRail::Event
26
+ # event_type "tests.order_placed"
27
+ # version 1
28
+ # default_source "tests"
29
+ #
30
+ # attribute :order_id, :string
31
+ # end
32
+ #
33
+ # class AuditJob < ApplicationJob
34
+ # subscribes_to Placed
35
+ #
36
+ # def perform(event) = (self.class.seen << event)
37
+ # end
38
+ # end
39
+ # end
40
+ #
41
+ # Contracts declared here are indexed when the window closes and stay indexed: a class
42
+ # cannot be unloaded, so two fixtures claiming one event type and version fail every
43
+ # later rebuild, not just the first. Give each fixture a unique type, and declare it at
44
+ # file scope -- a declaration inside a test method reopens the same constant and runs the
45
+ # writer again on a sealed registry.
46
+ #
47
+ # Subscriptions declared here are dormant. They receive nothing until `with_subscribers`
48
+ # activates them, so a fixture cannot fan out in a later test that never mentions it.
49
+ def self.declare(&block)
50
+ # Unqualified, so lexical lookup reaches the private Internal namespace that a
51
+ # qualified EventRail::Internal reference would be refused.
52
+ Internal::Registry.declare_fixtures(&block)
53
+ end
54
+
55
+ # Activates fixture subscribers for the duration of the block and restores the previous
56
+ # registry afterwards, including when the block raises. Nested calls are additive.
57
+ #
58
+ # test "publication fans out to the audit job" do
59
+ # with_subscribers(OrderTests::AuditJob) do
60
+ # publication = EventRail.publish(OrderTests::Placed.new(order_id: "o-1"))
61
+ #
62
+ # assert_enqueued_with job: OrderTests::AuditJob, args: [ publication.event ]
63
+ # end
64
+ # end
65
+ #
66
+ # The registry snapshot this replaces is process-wide, so activation is not safe under
67
+ # thread-based test parallelisation (`parallelize(with: :threads)`). Process-based
68
+ # parallelisation, which is the Rails default, is unaffected because each worker has its
69
+ # own registry.
70
+ def with_subscribers(*job_classes, &block)
71
+ Internal::Registry.activate(job_classes.flatten, &block)
72
+ end
73
+ end
74
+ end
@@ -1,3 +1,3 @@
1
1
  module EventRail
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: event_rail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Dmitriev
@@ -153,6 +153,7 @@ files:
153
153
  - lib/event_rail/publish.rb
154
154
  - lib/event_rail/railtie.rb
155
155
  - lib/event_rail/subscriptions.rb
156
+ - lib/event_rail/test_helper.rb
156
157
  - lib/event_rail/version.rb
157
158
  homepage: https://github.com/alexdmtv/event-rail
158
159
  licenses: