Module: Seams::Events::Publisher
- Defined in:
- lib/seams/events/publisher.rb
Overview
Public API for publishing and subscribing to inter-engine events.
Engines should always go through this module rather than calling the underlying adapter directly — it enforces the naming convention, checks the EventRegistry, and gives subscribers a simple block-takes-payload interface regardless of which adapter is in use.
Subscribers run synchronously in the publisher’s thread (the default ActiveSupport::Notifications adapter has no other mode). They should therefore enqueue background jobs for any side effect that talks to the network or could fail — never perform the side effect inline. Seams does not enforce this; treat it as a convention that the boundary review catches.
Class Method Summary collapse
- .adapter ⇒ Object
-
.attach_class(key, event_name, class_name:, method_name:) ⇒ Object
Reload-safe alternative to #attach_once.
-
.attach_once(key, event_name) ⇒ Object
Idempotent variant of #subscribe.
- .attach_once_mutex ⇒ Object
-
.attached_keys ⇒ Object
Internal — exposed for spec teardown only.
-
.orphan_subscriptions ⇒ Object
Walks every subscription and returns the names that no engine has registered as an emitted event.
- .publish(event_name, payload = {}) ⇒ Object
-
.reset! ⇒ Object
Tears down everything Publisher has registered with the adapter and clears the bookkeeping.
- .subscribe(event_name) ⇒ Object
-
.subscriptions ⇒ Object
Returns the list of event names that engines have subscribed to during this process’s lifetime.
- .unsubscribe(subscriber) ⇒ Object
Class Method Details
.adapter ⇒ Object
146 147 148 |
# File 'lib/seams/events/publisher.rb', line 146 def adapter @adapter ||= build_adapter end |
.attach_class(key, event_name, class_name:, method_name:) ⇒ Object
Reload-safe alternative to #attach_once. Stores the subscriber class as a STRING name and re-resolves Object.const_get on every dispatch — so when Rails autoreload swaps the constant for a freshly-loaded class object, the next event reaches the new code without a server restart.
The class_name MUST be a String (e.g. “Notifications::AuthSubscriber”). Passing the class object itself defeats the fix: it captures a reference to the pre-reload object and exhibits exactly the staleness bug this method exists to avoid.
The named class method is invoked via send, so it may be private — keeping subscribers’ handlers out of their public surface. Idempotent on (key, event_name) like #attach_once.
Example:
Publisher.attach_class(
:notifications_auth_subscriber,
"identity.signed_up.auth",
class_name: "Notifications::AuthSubscriber",
method_name: :handle_signed_up
)
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/seams/events/publisher.rb', line 101 def attach_class(key, event_name, class_name:, method_name:) unless class_name.is_a?(String) raise ArgumentError, "attach_class requires class_name as a String (got #{class_name.class}). " \ "Passing the class object captures a stale reference across Rails reloads — " \ "the very bug this method exists to prevent." end method_symbol = method_name.to_sym attach_once_mutex.synchronize do attached_keys[[key, event_name.to_s]] ||= subscribe(event_name) do |payload| Object.const_get(class_name).send(method_symbol, payload) end end end |
.attach_once(key, event_name) ⇒ Object
Idempotent variant of #subscribe. The first call attaches and remembers the (key, event_name) pair on Seams::Events::Publisher itself — a Rails autoreload that re-evaluates the subscriber class file does NOT lose this state, because Publisher is in the gem and isn’t reloaded. Subsequent calls with the same (key, event_name) are no-ops, preventing the “welcome email fires N times after N reloads” bug.
Synchronized so concurrent boot threads (e.g. Puma cluster pre-fork) can’t race-attach the same subscriber twice.
CAVEAT — Rails autoreload staleness: The block passed here closes over its lexical binding, which in practice means the subscriber CLASS object as it existed when attach! first ran. After Rails reloads the subscriber file, the constant points at a fresh class object, but THIS block still calls into the old one — so edits to the subscriber’s methods are invisible until a full server restart. For new code, prefer #attach_class which re-resolves the constant on every dispatch and so is reload-safe.
Use a per-subscriber-class symbol as the key:
Publisher.attach_once(:notifications_auth_subscriber,
"identity.signed_up.auth") { |payload| ... }
72 73 74 75 76 |
# File 'lib/seams/events/publisher.rb', line 72 def attach_once(key, event_name, &) attach_once_mutex.synchronize do attached_keys[[key, event_name.to_s]] ||= subscribe(event_name, &) end end |
.attach_once_mutex ⇒ Object
127 128 129 |
# File 'lib/seams/events/publisher.rb', line 127 def attach_once_mutex @attach_once_mutex ||= Mutex.new end |
.attached_keys ⇒ Object
Internal — exposed for spec teardown only.
123 124 125 |
# File 'lib/seams/events/publisher.rb', line 123 def attached_keys @attached_keys ||= {} end |
.orphan_subscriptions ⇒ Object
Walks every subscription and returns the names that no engine has registered as an emitted event. Hosts can call this from an after_initialize block (or in a CI smoke test) to catch typos like subscribing to “identity.signed_up.atuh”.
142 143 144 |
# File 'lib/seams/events/publisher.rb', line 142 def orphan_subscriptions subscriptions.reject { |name| EventRegistry.registered?(name) }.uniq end |
.publish(event_name, payload = {}) ⇒ Object
23 24 25 26 27 28 29 30 31 32 33 34 35 |
# File 'lib/seams/events/publisher.rb', line 23 def publish(event_name, payload = {}) Events.assert_valid_name!(event_name) name = event_name.to_s unless EventRegistry.registered?(name) raise UnregisteredEventError, "Event #{name.inspect} has not been registered. " \ "Declare it in the engine that emits it via " \ "Seams::EventRegistry.register(#{name.inspect}, emitted_by: '<EngineName>')." end adapter.publish(name, payload) end |
.reset! ⇒ Object
Tears down everything Publisher has registered with the adapter and clears the bookkeeping. Without the unsubscribe step, ActiveSupport::Notifications keeps the prior process’s subscribers alive in its global registry — so test runs that call reset! between examples accumulate stale subscribers that fire (and may raise on now-gone constants) on every publish in the next example.
157 158 159 160 161 162 163 164 |
# File 'lib/seams/events/publisher.rb', line 157 def reset! @attached_keys&.each_value do |subscriber| adapter.unsubscribe(subscriber) if subscriber end @adapter = nil @subscriptions = nil @attached_keys = nil end |
.subscribe(event_name) ⇒ Object
37 38 39 40 41 42 43 44 45 |
# File 'lib/seams/events/publisher.rb', line 37 def subscribe(event_name, &) Events.assert_valid_name!(event_name) name = event_name.to_s subscriptions << name unless subscriptions.include?(name) adapter.subscribe(name) do |*args| yield(args.last) end end |
.subscriptions ⇒ Object
Returns the list of event names that engines have subscribed to during this process’s lifetime. Useful for reporting and for the post-boot validation hook below.
134 135 136 |
# File 'lib/seams/events/publisher.rb', line 134 def subscriptions @subscriptions ||= [] end |
.unsubscribe(subscriber) ⇒ Object
118 119 120 |
# File 'lib/seams/events/publisher.rb', line 118 def unsubscribe(subscriber) adapter.unsubscribe(subscriber) end |