easyop 0.1.1 → 0.1.3

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: f110ef0a50149ab84f49b40209a05bbf152a995b802a8664c59ff95f0eb36ee1
4
- data.tar.gz: d35940933ad93ca75eba984ed9a83cad28045ba3492e6745bda41d4c95154ec5
3
+ metadata.gz: 4c77d9aa5925ea30b3de85fc74618f355ee3498223f79d6184cda21e95574b8d
4
+ data.tar.gz: 8bd775d8fa095a50b46aafeedec78f2046d54005b6316a1f22ae02b5657c361c
5
5
  SHA512:
6
- metadata.gz: 47f9214496757ec818887f4d9372d03b3e4d26c488927de696637212d1b3aef42f3fcefda00fff85aa6f6452a4e93ecbbd674f859f38fda974b7b77bead059ff
7
- data.tar.gz: 5544728ce871c2eac2b9dfe6dfc278cc0c4f3cbe38f648ce031d82a55f16eb3400b29c34a883092a5167d07284beadc061ab6ab54eaac695cb5a87f6d8486d84
6
+ metadata.gz: a01be26e27050c1569f85ee610043ca42268c75054355c4faf230b65599354d9a824b07955f4f13cda25672681d1c934bbeaec15d89be2cfd8ad55681dd29511
7
+ data.tar.gz: 41d8d5bf9ca01e02146764678c343eac787c2d5445670caa9e8c1a0f0b60ff26b57906f2fb4abd16b7e59d05b05fe65a1400cff4458934df0110bf1af10f5d3a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,163 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.3] — 2026-04-14
11
+
12
+ ### Added
13
+
14
+ - **Minitest test suite** — a full parallel test suite covering all 21 modules in the gem. Tests live in `test/` and run via `bundle exec rake test` (or `bundle exec ruby -Ilib:test ...`). The suite complements the existing RSpec specs and is tracked as a separate SimpleCov report (command name `'Minitest'`).
15
+
16
+ Coverage across 258 tests, 360 assertions:
17
+
18
+ | Area | Files |
19
+ |------|-------|
20
+ | Core | `ctx_test`, `operation_test`, `hooks_test`, `rescuable_test`, `schema_test`, `skip_test`, `flow_test`, `flow_builder_test` |
21
+ | Events infrastructure | `events/event_test`, `events/registry_test`, `events/bus/memory_test`, `events/bus/adapter_test`, `events/bus/custom_test`, `events/bus/active_support_notifications_test` |
22
+ | Plugins | `plugins/base_test`, `plugins/recording_test`, `plugins/instrumentation_test`, `plugins/async_test`, `plugins/transactional_test`, `plugins/events_test`, `plugins/event_handlers_test` |
23
+
24
+ Key test patterns: anonymous `Class.new` operations, `set_const` helper for named-constant scenarios (Recording, Async), shared stubs for `ActiveSupport::Notifications`, `ActiveRecord::Base`, `ActiveJob::Base`, and `String#constantize` — all in `test/test_helper.rb` so individual files stay focused.
25
+
26
+ - **`Rakefile`** — adds a `test` task (Minitest) as the default Rake task:
27
+
28
+ ```ruby
29
+ bundle exec rake test
30
+ # or simply:
31
+ bundle exec rake
32
+ ```
33
+
34
+ - **`rake` gem** added to the `development/test` group in `Gemfile`.
35
+
36
+ - **`Easyop::Events::Bus::Adapter`** — a new inheritable base class for custom bus implementations. Subclass this instead of `Bus::Base` when building a transport adapter (RabbitMQ, Kafka, Redis, etc.). Provides two protected utilities on top of `Bus::Base`:
37
+
38
+ - `_safe_invoke(handler, event)` — calls `handler.call(event)` and rescues `StandardError`, so one broken subscriber never prevents others from running
39
+ - `_compile_pattern(pattern)` — converts a glob string or exact string to a `Regexp`, memoized per unique pattern per bus instance (glob→Regexp conversion happens only once regardless of publish volume)
40
+
41
+ ```ruby
42
+ require "easyop/events/bus/adapter"
43
+
44
+ # Decorator: wrap any inner bus and add structured logging
45
+ class LoggingBus < Easyop::Events::Bus::Adapter
46
+ def initialize(inner = Easyop::Events::Bus::Memory.new)
47
+ super(); @inner = inner
48
+ end
49
+
50
+ def publish(event)
51
+ Rails.logger.info "[bus] #{event.name} payload=#{event.payload}"
52
+ @inner.publish(event)
53
+ end
54
+
55
+ def subscribe(pattern, &block) = @inner.subscribe(pattern, &block)
56
+ def unsubscribe(handle) = @inner.unsubscribe(handle)
57
+ end
58
+
59
+ Easyop::Events::Registry.bus = LoggingBus.new
60
+
61
+ # Full external broker example — RabbitMQ via Bunny gem:
62
+ class RabbitBus < Easyop::Events::Bus::Adapter
63
+ EXCHANGE_NAME = "easyop.events"
64
+ def initialize(url = ENV.fetch("AMQP_URL")) = (super(); @url = url)
65
+ def publish(event)
66
+ exchange.publish(event.to_h.to_json, routing_key: event.name)
67
+ end
68
+ def subscribe(pattern, &block)
69
+ q = channel.queue("", exclusive: true, auto_delete: true)
70
+ q.bind(exchange, routing_key: pattern.gsub("**", "#"))
71
+ q.subscribe { |_, _, body| _safe_invoke(block, decode(body)) }
72
+ end
73
+ private
74
+ def decode(body) = Easyop::Events::Event.new(**JSON.parse(body, symbolize_names: true))
75
+ def connection = @conn ||= Bunny.new(@url).tap(&:start)
76
+ def channel = @ch ||= connection.create_channel
77
+ def exchange = @exch ||= channel.topic(EXCHANGE_NAME, durable: true)
78
+ end
79
+ ```
80
+
81
+ - **`Plugins::Events`** — a new producer plugin that emits domain events after an operation completes. Install on any operation class and declare events with the `emits` DSL:
82
+
83
+ ```ruby
84
+ class PlaceOrder < ApplicationOperation
85
+ plugin Easyop::Plugins::Events
86
+
87
+ emits "order.placed", on: :success, payload: [:order_id, :total]
88
+ emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
89
+ emits "order.attempted", on: :always
90
+ end
91
+ ```
92
+
93
+ Options for `emits`: `on:` (`:success` / `:failure` / `:always`), `payload:` (Proc, Array of ctx keys, or nil for full ctx), `guard:` (optional Proc condition). Events fire in an `ensure` block so they are published even when `call!` raises `Ctx::Failure`. Individual publish failures are swallowed and never crash the operation. Subclasses inherit parent declarations.
94
+
95
+ - **`Plugins::EventHandlers`** — a new subscriber plugin that wires an operation as a domain event handler:
96
+
97
+ ```ruby
98
+ class SendConfirmation < ApplicationOperation
99
+ plugin Easyop::Plugins::EventHandlers
100
+
101
+ on "order.placed"
102
+
103
+ def call
104
+ OrderMailer.confirm(ctx.order_id).deliver_later
105
+ end
106
+ end
107
+
108
+ # Async dispatch (requires Plugins::Async also installed):
109
+ class IndexOrder < ApplicationOperation
110
+ plugin Easyop::Plugins::Async, queue: "indexing"
111
+ plugin Easyop::Plugins::EventHandlers
112
+
113
+ on "order.*", async: true
114
+ on "inventory.**", async: true, queue: "low"
115
+
116
+ def call
117
+ SearchIndex.reindex(ctx.order_id)
118
+ end
119
+ end
120
+ ```
121
+
122
+ Supports glob patterns: `"order.*"` matches within one segment; `"order.**"` matches across segments. Registration happens at class-load time. Handler operations receive `ctx.event` (the `Easyop::Events::Event` object) and payload keys merged into ctx.
123
+
124
+ - **`Easyop::Events::Event`** — immutable, frozen domain event value object. Carries `name`, `payload`, `source` (emitting class name), `metadata`, and `timestamp`. Serializable to a plain Hash via `#to_h`.
125
+
126
+ - **`Easyop::Events::Bus`** — pluggable bus adapter system with three built-in adapters:
127
+ - `Bus::Memory` — in-process synchronous bus (default). Thread-safe via Mutex. Supports glob patterns and Regexp subscriptions. Test-friendly: `clear!`, `subscriber_count`.
128
+ - `Bus::ActiveSupportNotifications` — wraps `ActiveSupport::Notifications`. Lazy-checks for the library.
129
+ - `Bus::Custom` — wraps any user object responding to `#publish` and `#subscribe`. Validates the interface at construction time.
130
+
131
+ - **`Easyop::Events::Registry`** — thread-safe global coordination point. Configure once at boot; handler subscriptions are registered against it at class-load time:
132
+
133
+ ```ruby
134
+ # Globally:
135
+ Easyop::Events::Registry.bus = :memory # default
136
+ Easyop::Events::Registry.bus = :active_support
137
+ Easyop::Events::Registry.bus = MyRabbitBus.new # custom adapter
138
+
139
+ # Or via config:
140
+ Easyop.configure { |c| c.event_bus = :active_support }
141
+ ```
142
+
143
+ - **`Easyop::Configuration#event_bus`** — new configuration key. Accepts `:memory`, `:active_support`, or a bus adapter instance.
144
+
145
+ ## [0.1.2] — 2026-04-13
146
+
147
+ ### Added
148
+
149
+ - **`Plugins::Async` — `queue` DSL** — a new `queue` class method for declaring the default queue directly on an operation class (or a shared base class). This lets subclasses override the queue set at plugin install time without re-declaring the plugin:
150
+
151
+ ```ruby
152
+ class Weather::BaseOperation < ApplicationOperation
153
+ queue :weather # all Weather ops use the "weather" queue
154
+ end
155
+
156
+ class Weather::FetchForecast < Weather::BaseOperation
157
+ # inherits queue :weather automatically
158
+ end
159
+
160
+ class Weather::CleanupExpiredDays < Weather::BaseOperation
161
+ queue :low_priority # override just for this class
162
+ end
163
+ ```
164
+
165
+ Accepts both `Symbol` and `String`. The setting is inherited by subclasses and can be overridden at any level of the hierarchy.
166
+
10
167
  ## [0.1.1] — 2026-04-01
11
168
 
12
169
  ### Fixed
@@ -43,6 +200,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
200
  - `examples/easyop_test_app/` — full Rails 8 blog application demonstrating all features in real-world code
44
201
  - `examples/usage.rb` — 13 runnable plain-Ruby examples
45
202
 
46
- [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.1...HEAD
47
- [0.1.1]: https://github.com/pniemczyk/easyop/compare/v0.1.0...v0.1.1
48
- [0.1.0]: https://github.com/pniemczyk/easyop/releases/tag/v0.1.0
203
+ [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.2...HEAD
204
+ [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
data/README.md CHANGED
@@ -672,6 +672,20 @@ Newsletter::SendBroadcast.call_async(attrs, wait_until: Date.tomorrow.noon)
672
672
  Newsletter::SendBroadcast.call_async(attrs, queue: "low_priority")
673
673
  ```
674
674
 
675
+ **`queue` DSL** — declare the default queue directly on a class (or a shared base class) instead of at plugin install time:
676
+
677
+ ```ruby
678
+ class Weather::BaseOperation < ApplicationOperation
679
+ queue :weather # all Weather ops use the "weather" queue by default
680
+ end
681
+
682
+ class Weather::CleanupExpiredDays < Weather::BaseOperation
683
+ queue :low_priority # override just for this class
684
+ end
685
+ ```
686
+
687
+ The `queue` setting is inherited by subclasses and can be overridden at any level. Accepts `Symbol` or `String`. A per-call `queue:` argument to `.call_async` always takes precedence.
688
+
675
689
  **ActiveRecord objects** are serialized by `(class, id)` and re-fetched in the job:
676
690
 
677
691
  ```ruby
@@ -685,6 +699,198 @@ The plugin defines `Easyop::Plugins::Async::Job` lazily (on first call to `.call
685
699
 
686
700
  ---
687
701
 
702
+ ### Plugin: Events
703
+
704
+ Emits domain events after an operation completes. Unlike the Instrumentation plugin (which is for operation-level tracing), Events carries business domain events through a configurable bus.
705
+
706
+ ```ruby
707
+ require "easyop/events/event"
708
+ require "easyop/events/bus"
709
+ require "easyop/events/bus/memory"
710
+ require "easyop/events/registry"
711
+ require "easyop/plugins/events"
712
+ require "easyop/plugins/event_handlers"
713
+
714
+ class PlaceOrder < ApplicationOperation
715
+ plugin Easyop::Plugins::Events
716
+
717
+ emits "order.placed", on: :success, payload: [:order_id, :total]
718
+ emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
719
+ emits "order.attempted", on: :always
720
+
721
+ def call
722
+ ctx.order_id = Order.create!(ctx.to_h).id
723
+ end
724
+ end
725
+ ```
726
+
727
+ **`emits` DSL options:**
728
+
729
+ | Option | Values | Default | Description |
730
+ |---|---|---|---|
731
+ | `on:` | `:success`, `:failure`, `:always` | `:success` | When to fire |
732
+ | `payload:` | `Proc`, `Array`, `nil` | `nil` (full ctx) | Proc receives ctx; Array slices ctx keys |
733
+ | `guard:` | `Proc`, `nil` | `nil` | Extra condition — fires only when truthy |
734
+
735
+ Events fire in an `ensure` block and are emitted even when `call!` raises. Individual publish failures are swallowed and never crash the operation. `emits` declarations are inherited by subclasses.
736
+
737
+ ---
738
+
739
+ ### Plugin: EventHandlers
740
+
741
+ Wires an operation class as a domain event handler. Handler operations receive `ctx.event` (the `Easyop::Events::Event` object) and all payload keys merged into ctx.
742
+
743
+ ```ruby
744
+ class SendConfirmation < ApplicationOperation
745
+ plugin Easyop::Plugins::EventHandlers
746
+
747
+ on "order.placed"
748
+
749
+ def call
750
+ event = ctx.event # Easyop::Events::Event
751
+ order_id = ctx.order_id # payload keys merged into ctx
752
+ OrderMailer.confirm(order_id).deliver_later
753
+ end
754
+ end
755
+ ```
756
+
757
+ **Async dispatch** (requires `Plugins::Async` also installed):
758
+
759
+ ```ruby
760
+ class IndexOrder < ApplicationOperation
761
+ plugin Easyop::Plugins::Async, queue: "indexing"
762
+ plugin Easyop::Plugins::EventHandlers
763
+
764
+ on "order.*", async: true
765
+ on "inventory.**", async: true, queue: "low"
766
+
767
+ def call
768
+ # ctx.event_data is a Hash when dispatched async (serializable for ActiveJob)
769
+ SearchIndex.reindex(ctx.order_id)
770
+ end
771
+ end
772
+ ```
773
+
774
+ **Glob patterns:**
775
+ - `"order.*"` — matches within one dot-segment (`order.placed`, `order.shipped`)
776
+ - `"warehouse.**"` — matches across segments (`warehouse.stock.updated`, `warehouse.zone.moved`)
777
+ - Plain strings match exactly; `Regexp` is also accepted
778
+
779
+ Registration happens at class-load time. Configure the bus **before** loading handler classes.
780
+
781
+ ---
782
+
783
+ ### Events Bus — Configurable Transport
784
+
785
+ The bus adapter controls how events are delivered. Configure it once at boot:
786
+
787
+ ```ruby
788
+ # Default — in-process synchronous (great for tests and simple setups)
789
+ Easyop::Events::Registry.bus = :memory
790
+
791
+ # ActiveSupport::Notifications (integrates with Rails instrumentation)
792
+ Easyop::Events::Registry.bus = :active_support
793
+
794
+ # Any custom adapter (RabbitMQ, Kafka, Redis Pub/Sub…)
795
+ Easyop::Events::Registry.bus = MyRabbitBus.new
796
+
797
+ # Or via config block:
798
+ Easyop.configure { |c| c.event_bus = :active_support }
799
+ ```
800
+
801
+ **Built-in adapters:**
802
+
803
+ | Adapter | Class | Notes |
804
+ |---|---|---|
805
+ | Memory | `Easyop::Events::Bus::Memory` | Default. Thread-safe, in-process, synchronous. |
806
+ | ActiveSupport | `Easyop::Events::Bus::ActiveSupportNotifications` | Wraps `AS::Notifications`. Requires `activesupport`. |
807
+ | Custom | `Easyop::Events::Bus::Custom` | Wraps any object with `#publish` and `#subscribe`. |
808
+
809
+ **Building a custom bus — two approaches:**
810
+
811
+ **Option A — subclass `Bus::Adapter`** (recommended for real transports). Inherits glob helpers, `_safe_invoke`, and `_compile_pattern`:
812
+
813
+ ```ruby
814
+ require "easyop/events/bus/adapter"
815
+
816
+ # Decorator: wraps any inner bus and adds structured logging
817
+ class LoggingBus < Easyop::Events::Bus::Adapter
818
+ def initialize(inner = Easyop::Events::Bus::Memory.new)
819
+ super()
820
+ @inner = inner
821
+ end
822
+
823
+ def publish(event)
824
+ Rails.logger.info "[bus:publish] #{event.name} payload=#{event.payload}"
825
+ @inner.publish(event)
826
+ end
827
+
828
+ def subscribe(pattern, &block) = @inner.subscribe(pattern, &block)
829
+ def unsubscribe(handle) = @inner.unsubscribe(handle)
830
+ end
831
+
832
+ Easyop::Events::Registry.bus = LoggingBus.new
833
+
834
+ # Full RabbitMQ example (Bunny gem) — uses _safe_invoke for handler safety:
835
+ class RabbitBus < Easyop::Events::Bus::Adapter
836
+ EXCHANGE_NAME = "easyop.events"
837
+
838
+ def initialize(url = ENV.fetch("AMQP_URL", "amqp://localhost"))
839
+ super()
840
+ @url = url; @mutex = Mutex.new; @handles = {}
841
+ end
842
+
843
+ def publish(event)
844
+ exchange.publish(event.to_h.to_json,
845
+ routing_key: event.name, content_type: "application/json")
846
+ end
847
+
848
+ def subscribe(pattern, &block)
849
+ q = channel.queue("", exclusive: true, auto_delete: true)
850
+ q.bind(exchange, routing_key: _to_amqp(pattern))
851
+ consumer = q.subscribe { |_, _, body| _safe_invoke(block, decode(body)) }
852
+ handle = Object.new
853
+ @mutex.synchronize { @handles[handle.object_id] = { queue: q, consumer: consumer } }
854
+ handle
855
+ end
856
+
857
+ def unsubscribe(handle)
858
+ @mutex.synchronize do
859
+ e = @handles.delete(handle.object_id); return unless e
860
+ e[:consumer].cancel; e[:queue].delete
861
+ end
862
+ end
863
+
864
+ def disconnect
865
+ @mutex.synchronize { @connection&.close; @connection = @channel = @exchange = nil }
866
+ end
867
+
868
+ private
869
+
870
+ def _to_amqp(p) = p.is_a?(Regexp) ? p.source : p.gsub("**", "#")
871
+ def decode(body) = Easyop::Events::Event.new(**JSON.parse(body, symbolize_names: true))
872
+ def connection = @connection ||= Bunny.new(@url, recover_from_connection_close: true).tap(&:start)
873
+ def channel = @channel ||= connection.create_channel
874
+ def exchange = @exchange ||= channel.topic(EXCHANGE_NAME, durable: true)
875
+ end
876
+
877
+ Easyop::Events::Registry.bus = RabbitBus.new
878
+ at_exit { Easyop::Events::Registry.bus.disconnect }
879
+ ```
880
+
881
+ **Option B — duck-typed object** (no subclassing). Pass any object with `#publish` and `#subscribe`; Registry auto-wraps it in `Bus::Custom`:
882
+
883
+ ```ruby
884
+ class MyKafkaBus
885
+ def publish(event) = Kafka.produce(event.name, event.to_h.to_json)
886
+ def subscribe(pattern, &block) = Kafka.subscribe(pattern) { |msg| block.call(decode(msg)) }
887
+ end
888
+
889
+ Easyop::Events::Registry.bus = MyKafkaBus.new
890
+ ```
891
+
892
+ ---
893
+
688
894
  ### Plugin: Transactional
689
895
 
690
896
  Wraps every operation call in a database transaction. On `ctx.fail!` or any unhandled exception the transaction is rolled back. Supports **ActiveRecord** and **Sequel**.
@@ -1108,3 +1314,84 @@ See the **[AI Tools docs page](https://pniemczyk.github.io/easyop/ai-tools.html)
1108
1314
  | `Easyop::Plugins::Async` | `easyop/plugins/async` | Adds `.call_async` via ActiveJob with AR object serialization |
1109
1315
  | `Easyop::Plugins::Async::Job` | (created lazily) | The ActiveJob class that deserializes and runs the operation |
1110
1316
  | `Easyop::Plugins::Transactional` | `easyop/plugins/transactional` | Wraps operation in an AR/Sequel transaction; `transactional false` to opt out |
1317
+ | `Easyop::Plugins::Events` | `easyop/plugins/events` | Emits domain events after execution; `emits` DSL with `on:`, `payload:`, `guard:` |
1318
+ | `Easyop::Plugins::EventHandlers` | `easyop/plugins/event_handlers` | Subscribes an operation to handle domain events; `on` DSL with glob patterns |
1319
+
1320
+ ### Domain Events Infrastructure
1321
+
1322
+ | Class/Module | Require | Description |
1323
+ |---|---|---|
1324
+ | `Easyop::Events::Event` | `easyop/events/event` | Immutable frozen value object: `name`, `payload`, `metadata`, `timestamp`, `source` |
1325
+ | `Easyop::Events::Bus::Base` | `easyop/events/bus` | Abstract adapter interface (`publish`, `subscribe`, `unsubscribe`) and glob helpers |
1326
+ | `Easyop::Events::Bus::Adapter` | `easyop/events/bus/adapter` | **Inheritable base for custom buses.** Adds `_safe_invoke` + `_compile_pattern` (memoized). Subclass this. |
1327
+ | `Easyop::Events::Bus::Memory` | `easyop/events/bus/memory` | In-process synchronous bus (default). Thread-safe. |
1328
+ | `Easyop::Events::Bus::ActiveSupportNotifications` | `easyop/events/bus/active_support_notifications` | `ActiveSupport::Notifications` adapter |
1329
+ | `Easyop::Events::Bus::Custom` | `easyop/events/bus/custom` | Wraps any user-provided bus object (duck-typed, no subclassing needed) |
1330
+ | `Easyop::Events::Registry` | `easyop/events/registry` | Global bus holder + handler subscription registry |
1331
+
1332
+ ---
1333
+
1334
+ ## Releasing a New Version
1335
+
1336
+ Follow these steps to bump the version, update the changelog, and publish a tagged release.
1337
+
1338
+ ### 1. Bump the version number
1339
+
1340
+ Edit `lib/easyop/version.rb` and increment the version string following [Semantic Versioning](https://semver.org/):
1341
+
1342
+ ```ruby
1343
+ # lib/easyop/version.rb
1344
+ module Easyop
1345
+ VERSION = "0.1.4" # was 0.1.3
1346
+ end
1347
+ ```
1348
+
1349
+ ### 2. Update the changelog
1350
+
1351
+ In `CHANGELOG.md`, move everything under `[Unreleased]` into a new versioned section:
1352
+
1353
+ ```markdown
1354
+ ## [Unreleased]
1355
+
1356
+ ## [0.1.4] — YYYY-MM-DD # ← new section
1357
+
1358
+ ### Added
1359
+ - …
1360
+
1361
+ ## [0.1.3] — 2026-04-14
1362
+ ```
1363
+
1364
+ Add a comparison link at the bottom of the file:
1365
+
1366
+ ```markdown
1367
+ [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.4...HEAD
1368
+ [0.1.4]: https://github.com/pniemczyk/easyop/compare/v0.1.3...v0.1.4
1369
+ [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
1370
+ ```
1371
+
1372
+ ### 3. Commit the release changes
1373
+
1374
+ ```bash
1375
+ git add lib/easyop/version.rb CHANGELOG.md
1376
+ git commit -m "Release v0.1.4"
1377
+ ```
1378
+
1379
+ ### 4. Tag the commit
1380
+
1381
+ ```bash
1382
+ git tag -a v0.1.4 -m "Release v0.1.4"
1383
+ ```
1384
+
1385
+ ### 5. Push the commit and tag
1386
+
1387
+ ```bash
1388
+ git push origin master
1389
+ git push origin v0.1.4
1390
+ ```
1391
+
1392
+ ### 6. Build and push the gem (optional)
1393
+
1394
+ ```bash
1395
+ gem build easyop.gemspec
1396
+ gem push easyop-0.1.4.gem
1397
+ ```
@@ -8,9 +8,17 @@ module Easyop
8
8
  # When false (default), mismatches emit a warning and execution continues.
9
9
  attr_accessor :strict_types
10
10
 
11
+ # Bus adapter for domain events (Easyop::Plugins::Events / EventHandlers).
12
+ # Options: :memory (default), :active_support, or a bus adapter instance.
13
+ #
14
+ # @example
15
+ # Easyop.configure { |c| c.event_bus = :active_support }
16
+ attr_accessor :event_bus
17
+
11
18
  def initialize
12
19
  @type_adapter = :native
13
20
  @strict_types = false
21
+ @event_bus = nil # nil = Memory bus (see Easyop::Events::Registry)
14
22
  end
15
23
  end
16
24
 
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # Bus adapter backed by ActiveSupport::Notifications.
7
+ #
8
+ # Requires activesupport (not auto-required). Raises LoadError if unavailable.
9
+ #
10
+ # @example
11
+ # Easyop::Events::Registry.bus = :active_support
12
+ #
13
+ # # or manually:
14
+ # bus = Easyop::Events::Bus::ActiveSupportNotifications.new
15
+ # Easyop::Events::Registry.bus = bus
16
+ class ActiveSupportNotifications < Base
17
+ # Publish +event+ via ActiveSupport::Notifications.instrument.
18
+ # The full event hash (name, payload, metadata, timestamp, source) is passed
19
+ # as the AS notification payload.
20
+ #
21
+ # @param event [Easyop::Events::Event]
22
+ def publish(event)
23
+ _ensure_as!
24
+ ::ActiveSupport::Notifications.instrument(event.name, event.to_h)
25
+ end
26
+
27
+ # Subscribe to events matching +pattern+ via ActiveSupport::Notifications.subscribe.
28
+ # Glob patterns are converted to Regexp before passing to AS.
29
+ #
30
+ # @param pattern [String, Regexp]
31
+ # @return [Object] AS subscription handle
32
+ def subscribe(pattern, &block)
33
+ _ensure_as!
34
+
35
+ as_pattern = _as_pattern(pattern)
36
+
37
+ ::ActiveSupport::Notifications.subscribe(as_pattern) do |*args|
38
+ as_event = ::ActiveSupport::Notifications::Event.new(*args)
39
+ p = as_event.payload
40
+
41
+ # Reconstruct an Easyop::Events::Event from the AS notification payload.
42
+ easyop_event = Event.new(
43
+ name: (p[:name] || as_event.name).to_s,
44
+ payload: p[:payload] || {},
45
+ metadata: p[:metadata] || {},
46
+ timestamp: p[:timestamp],
47
+ source: p[:source]
48
+ )
49
+ block.call(easyop_event)
50
+ end
51
+ end
52
+
53
+ # Unsubscribe using the handle returned by #subscribe.
54
+ # @param handle [Object] AS subscription object
55
+ def unsubscribe(handle)
56
+ _ensure_as!
57
+ ::ActiveSupport::Notifications.unsubscribe(handle)
58
+ end
59
+
60
+ private
61
+
62
+ # Convert a pattern for use with AS::Notifications.subscribe.
63
+ # AS accepts exact strings or Regexp (not globs), so convert globs first.
64
+ def _as_pattern(pattern)
65
+ case pattern
66
+ when Regexp then pattern
67
+ when String
68
+ pattern.include?("*") ? _glob_to_regex(pattern) : pattern
69
+ end
70
+ end
71
+
72
+ def _ensure_as!
73
+ return if defined?(::ActiveSupport::Notifications)
74
+
75
+ raise LoadError,
76
+ "ActiveSupport::Notifications is required for this bus adapter. " \
77
+ "Add 'activesupport' to your Gemfile."
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end