easyop 0.1.2 → 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: 9157dccd94c6847b4b83c01404b64d48ffe54f1f6f51a99e0263bb718a2acdc4
4
- data.tar.gz: 62bbaa697722e2b0d1979bc5a20839fb9dc765ee22d989f404591129a8a72c01
3
+ metadata.gz: 4c77d9aa5925ea30b3de85fc74618f355ee3498223f79d6184cda21e95574b8d
4
+ data.tar.gz: 8bd775d8fa095a50b46aafeedec78f2046d54005b6316a1f22ae02b5657c361c
5
5
  SHA512:
6
- metadata.gz: 52fc250d99c11808462344afb74603081aa2dd35ca4f4d16ec263d19b3c098bba77ddcdda7963793483a9bfc0b04bd2cf63a70700c9756e0fb6b4610229ecc9f
7
- data.tar.gz: 778348d6dc863bdda5816a32738ae2262c3948eb3711090b0a4412bc0678a9dc9b7c87e48cd937c3f1842e492f5be787c252c42f8ae9955191794cc9200dfc1e
6
+ metadata.gz: a01be26e27050c1569f85ee610043ca42268c75054355c4faf230b65599354d9a824b07955f4f13cda25672681d1c934bbeaec15d89be2cfd8ad55681dd29511
7
+ data.tar.gz: 41d8d5bf9ca01e02146764678c343eac787c2d5445670caa9e8c1a0f0b60ff26b57906f2fb4abd16b7e59d05b05fe65a1400cff4458934df0110bf1af10f5d3a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,141 @@ 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
+
10
145
  ## [0.1.2] — 2026-04-13
11
146
 
12
147
  ### Added
@@ -66,6 +201,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66
201
  - `examples/usage.rb` — 13 runnable plain-Ruby examples
67
202
 
68
203
  [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.2...HEAD
69
- [0.1.2]: https://github.com/pniemczyk/easyop/compare/v0.1.1...v0.1.2
70
- [0.1.1]: https://github.com/pniemczyk/easyop/compare/v0.1.0...v0.1.1
71
- [0.1.0]: https://github.com/pniemczyk/easyop/releases/tag/v0.1.0
204
+ [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
data/README.md CHANGED
@@ -699,6 +699,198 @@ The plugin defines `Easyop::Plugins::Async::Job` lazily (on first call to `.call
699
699
 
700
700
  ---
701
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
+
702
894
  ### Plugin: Transactional
703
895
 
704
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**.
@@ -1122,3 +1314,84 @@ See the **[AI Tools docs page](https://pniemczyk.github.io/easyop/ai-tools.html)
1122
1314
  | `Easyop::Plugins::Async` | `easyop/plugins/async` | Adds `.call_async` via ActiveJob with AR object serialization |
1123
1315
  | `Easyop::Plugins::Async::Job` | (created lazily) | The ActiveJob class that deserializes and runs the operation |
1124
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
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # Inheritable base class for custom bus implementations.
7
+ #
8
+ # Subclass this (not +Bus::Base+) when building a transport adapter for
9
+ # an external message broker, pub/sub system, or any custom delivery
10
+ # mechanism. +Bus::Base+ defines the required interface and glob helpers.
11
+ # +Bus::Adapter+ adds two production-grade utilities on top:
12
+ #
13
+ # _safe_invoke(handler, event) — call a handler, swallow StandardError
14
+ # _compile_pattern(pattern) — glob/string → cached Regexp
15
+ #
16
+ # Both are protected so they are accessible in subclasses but not part
17
+ # of the external bus interface.
18
+ #
19
+ # == Minimum contract
20
+ #
21
+ # Override +#publish+ and +#subscribe+. Override +#unsubscribe+ if your
22
+ # transport supports subscription cancellation.
23
+ #
24
+ # == Example — minimal adapter
25
+ #
26
+ # class PrintBus < Easyop::Events::Bus::Adapter
27
+ # def initialize
28
+ # super
29
+ # @subs = []
30
+ # @mutex = Mutex.new
31
+ # end
32
+ #
33
+ # def publish(event)
34
+ # snap = @mutex.synchronize { @subs.dup }
35
+ # snap.each do |sub|
36
+ # _safe_invoke(sub[:handler], event) if _pattern_matches?(sub[:pattern], event.name)
37
+ # end
38
+ # end
39
+ #
40
+ # def subscribe(pattern, &block)
41
+ # handle = { pattern: _compile_pattern(pattern), handler: block }
42
+ # @mutex.synchronize { @subs << handle }
43
+ # handle
44
+ # end
45
+ #
46
+ # def unsubscribe(handle)
47
+ # @mutex.synchronize { @subs.delete(handle) }
48
+ # end
49
+ # end
50
+ #
51
+ # == Example — decorator (wraps another bus)
52
+ #
53
+ # class LoggingBus < Easyop::Events::Bus::Adapter
54
+ # def initialize(inner)
55
+ # super()
56
+ # @inner = inner
57
+ # end
58
+ #
59
+ # def publish(event)
60
+ # Rails.logger.info "[bus:publish] #{event.name} payload=#{event.payload}"
61
+ # @inner.publish(event)
62
+ # end
63
+ #
64
+ # def subscribe(pattern, &block)
65
+ # @inner.subscribe(pattern, &block)
66
+ # end
67
+ #
68
+ # def unsubscribe(handle)
69
+ # @inner.unsubscribe(handle)
70
+ # end
71
+ # end
72
+ #
73
+ # Easyop::Events::Registry.bus = LoggingBus.new(Easyop::Events::Bus::Memory.new)
74
+ class Adapter < Base
75
+ protected
76
+
77
+ # Call +handler+ with +event+, rescuing any StandardError.
78
+ #
79
+ # Use this inside your +#publish+ implementation so one broken handler
80
+ # never prevents other handlers from running and never surfaces an
81
+ # exception to the event producer.
82
+ #
83
+ # @param handler [#call]
84
+ # @param event [Easyop::Events::Event]
85
+ # @return [void]
86
+ def _safe_invoke(handler, event)
87
+ handler.call(event)
88
+ rescue StandardError
89
+ # Intentionally swallowed — individual handler failures must not
90
+ # propagate to the caller or block remaining handlers.
91
+ end
92
+
93
+ # Compile +pattern+ into a +Regexp+, memoized per unique pattern value.
94
+ #
95
+ # Results are cached in a per-instance hash so glob→Regexp conversion
96
+ # happens only once regardless of how many events are published.
97
+ #
98
+ # String patterns without wildcards are anchored literally.
99
+ # Glob patterns follow the same rules as +Bus::Base#_glob_to_regex+:
100
+ # "*" — any single dot-separated segment
101
+ # "**" — any sequence of characters including dots
102
+ #
103
+ # @param pattern [String, Regexp]
104
+ # @return [Regexp]
105
+ def _compile_pattern(pattern)
106
+ return pattern if pattern.is_a?(Regexp)
107
+
108
+ @_pattern_cache ||= {}
109
+ @_pattern_cache[pattern] ||=
110
+ if pattern.include?("*")
111
+ _glob_to_regex(pattern)
112
+ else
113
+ Regexp.new("\\A#{Regexp.escape(pattern)}\\z")
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # Wraps any user-supplied bus adapter.
7
+ #
8
+ # The adapter must respond to:
9
+ # #publish(event)
10
+ # #subscribe(pattern, &block)
11
+ #
12
+ # Optionally:
13
+ # #unsubscribe(handle)
14
+ #
15
+ # @example Wrapping a RabbitMQ adapter
16
+ # class MyRabbitBus
17
+ # def publish(event) = rabbit.publish(event.to_h, routing_key: event.name)
18
+ # def subscribe(pattern, &block) = rabbit.subscribe(pattern) { |msg| block.call(reconstruct(msg)) }
19
+ # end
20
+ #
21
+ # Easyop::Events::Registry.bus = MyRabbitBus.new
22
+ #
23
+ # @example Passing via Custom wrapper explicitly
24
+ # Easyop::Events::Registry.bus = Easyop::Events::Bus::Custom.new(MyRabbitBus.new)
25
+ class Custom < Base
26
+ # @param adapter [Object] must respond to #publish and #subscribe
27
+ # @raise [ArgumentError] if adapter does not meet the interface
28
+ def initialize(adapter)
29
+ unless adapter.respond_to?(:publish) && adapter.respond_to?(:subscribe)
30
+ raise ArgumentError,
31
+ "Custom bus adapter must respond to #publish(event) and " \
32
+ "#subscribe(pattern, &block). Got: #{adapter.inspect}"
33
+ end
34
+ @adapter = adapter
35
+ end
36
+
37
+ def publish(event)
38
+ @adapter.publish(event)
39
+ end
40
+
41
+ def subscribe(pattern, &block)
42
+ @adapter.subscribe(pattern, &block)
43
+ end
44
+
45
+ def unsubscribe(handle)
46
+ @adapter.unsubscribe(handle) if @adapter.respond_to?(:unsubscribe)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ module Bus
6
+ # In-process, synchronous bus. Default adapter — requires no external gems.
7
+ #
8
+ # Thread-safe: subscriptions are protected by a Mutex, and publish snapshots
9
+ # the subscriber list before calling handlers so the lock is not held
10
+ # during handler execution (prevents deadlocks).
11
+ #
12
+ # @example
13
+ # bus = Easyop::Events::Bus::Memory.new
14
+ # bus.subscribe("order.placed") { |e| puts e.name }
15
+ # bus.publish(Easyop::Events::Event.new(name: "order.placed"))
16
+ class Memory < Base
17
+ def initialize
18
+ @subscribers = []
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # Deliver +event+ to all matching subscribers.
23
+ # Handlers are called outside the lock; failures in individual handlers
24
+ # are swallowed so other handlers still run.
25
+ #
26
+ # @param event [Easyop::Events::Event]
27
+ def publish(event)
28
+ subs = @mutex.synchronize { @subscribers.dup }
29
+ subs.each do |sub|
30
+ sub[:handler].call(event) if _pattern_matches?(sub[:pattern], event.name)
31
+ rescue StandardError
32
+ # Individual handler failures must not prevent other handlers from running.
33
+ end
34
+ end
35
+
36
+ # Register a handler block for events matching +pattern+.
37
+ #
38
+ # @param pattern [String, Regexp] exact name, glob, or Regexp
39
+ # @return [Hash] subscription handle (pass to #unsubscribe to remove)
40
+ def subscribe(pattern, &block)
41
+ entry = { pattern: pattern, handler: block }
42
+ @mutex.synchronize { @subscribers << entry }
43
+ entry
44
+ end
45
+
46
+ # Remove a previously registered subscription.
47
+ # @param handle [Hash] the value returned by #subscribe
48
+ def unsubscribe(handle)
49
+ @mutex.synchronize { @subscribers.delete(handle) }
50
+ end
51
+
52
+ # Remove all subscriptions. Useful in tests.
53
+ def clear!
54
+ @mutex.synchronize { @subscribers.clear }
55
+ end
56
+
57
+ # @return [Integer] number of active subscriptions
58
+ def subscriber_count
59
+ @mutex.synchronize { @subscribers.size }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ # Namespace for bus adapters.
6
+ # All adapters inherit from Easyop::Events::Bus::Base.
7
+ module Bus
8
+ # Abstract base for bus adapters.
9
+ #
10
+ # Subclasses must implement:
11
+ # #publish(event) — deliver event to matching subscribers
12
+ # #subscribe(pattern, &block) — register a handler block
13
+ #
14
+ # Optionally override:
15
+ # #unsubscribe(handle) — remove a subscription (handle is return value of #subscribe)
16
+ class Base
17
+ # @param event [Easyop::Events::Event]
18
+ def publish(event)
19
+ raise NotImplementedError, "#{self.class}#publish must be implemented"
20
+ end
21
+
22
+ # @param pattern [String, Regexp] event name, glob ("order.*"), or Regexp
23
+ # @yield [event] called when a matching event is published
24
+ # @return [Object] subscription handle (adapter-specific)
25
+ def subscribe(pattern, &block)
26
+ raise NotImplementedError, "#{self.class}#subscribe must be implemented"
27
+ end
28
+
29
+ # Remove a subscription created by #subscribe.
30
+ # @param handle [Object] the value returned by #subscribe
31
+ def unsubscribe(handle)
32
+ # default no-op — adapters may override
33
+ end
34
+
35
+ private
36
+
37
+ # Returns true when +pattern+ matches +event_name+.
38
+ # Supports exact strings, glob patterns ("order.*", "order.**"), and Regexp.
39
+ #
40
+ # Glob rules:
41
+ # "*" — matches any segment that doesn't cross a dot
42
+ # "**" — matches any string including dots (greedy)
43
+ #
44
+ # @param pattern [String, Regexp]
45
+ # @param event_name [String]
46
+ def _pattern_matches?(pattern, event_name)
47
+ case pattern
48
+ when Regexp
49
+ pattern.match?(event_name)
50
+ when String
51
+ pattern.include?("*") ? _glob_to_regex(pattern).match?(event_name) : pattern == event_name
52
+ end
53
+ end
54
+
55
+ # @param glob [String] e.g. "order.*" or "warehouse.**"
56
+ # @return [Regexp]
57
+ def _glob_to_regex(glob)
58
+ escaped = Regexp.escape(glob)
59
+ .gsub("\\*\\*", ".+")
60
+ .gsub("\\*", "[^.]+")
61
+ Regexp.new("\\A#{escaped}\\z")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ # An immutable domain event value object.
6
+ #
7
+ # @example
8
+ # event = Easyop::Events::Event.new(
9
+ # name: "order.placed",
10
+ # payload: { order_id: 42, total: 9900 },
11
+ # source: "PlaceOrder"
12
+ # )
13
+ # event.name # => "order.placed"
14
+ # event.payload # => { order_id: 42, total: 9900 }
15
+ # event.frozen? # => true
16
+ class Event
17
+ attr_reader :name, :payload, :metadata, :timestamp, :source
18
+
19
+ # @param name [String] event name, e.g. "order.placed"
20
+ # @param payload [Hash] domain data extracted from ctx
21
+ # @param metadata [Hash] extra envelope data (correlation_id, etc.)
22
+ # @param timestamp [Time] defaults to Time.now
23
+ # @param source [String] class name of the emitting operation
24
+ def initialize(name:, payload: {}, metadata: {}, timestamp: nil, source: nil)
25
+ @name = name.to_s.freeze
26
+ @payload = payload.freeze
27
+ @metadata = metadata.freeze
28
+ @timestamp = (timestamp || Time.now).freeze
29
+ @source = source&.freeze
30
+ freeze
31
+ end
32
+
33
+ # @return [Hash] serializable hash representation
34
+ def to_h
35
+ { name: @name, payload: @payload, metadata: @metadata,
36
+ timestamp: @timestamp, source: @source }
37
+ end
38
+
39
+ def inspect
40
+ "#<Easyop::Events::Event name=#{@name.inspect} source=#{@source.inspect}>"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Events
5
+ # Thread-safe global registry for the event bus and handler subscriptions.
6
+ #
7
+ # The Registry is the coordination point between the Events plugin (producer)
8
+ # and the EventHandlers plugin (subscriber). Neither plugin references the other
9
+ # directly — they communicate only through the bus managed here.
10
+ #
11
+ # Configure the bus once at boot (e.g. in a Rails initializer):
12
+ #
13
+ # Easyop::Events::Registry.bus = :memory # default, in-process
14
+ # Easyop::Events::Registry.bus = :active_support # ActiveSupport::Notifications
15
+ # Easyop::Events::Registry.bus = MyRabbitBus.new # custom adapter
16
+ #
17
+ # Or via the global config:
18
+ #
19
+ # Easyop.configure { |c| c.event_bus = :active_support }
20
+ #
21
+ # IMPORTANT: Configure the bus BEFORE handler classes are loaded (before
22
+ # autoloading runs in Rails). Subscriptions are registered at class load time
23
+ # and are bound to whatever bus is active at that moment.
24
+ class Registry
25
+ @mutex = Mutex.new
26
+
27
+ class << self
28
+ # Set the global bus adapter.
29
+ #
30
+ # @param bus_or_symbol [:memory, :active_support, Bus::Base, Object]
31
+ def bus=(bus_or_symbol)
32
+ @mutex.synchronize { @bus = _resolve_bus(bus_or_symbol) }
33
+ end
34
+
35
+ # Returns the active bus adapter. Defaults to a Memory bus.
36
+ #
37
+ # Falls back to Easyop.config.event_bus if set, then :memory.
38
+ #
39
+ # @return [Bus::Base]
40
+ def bus
41
+ @mutex.synchronize do
42
+ @bus ||= _resolve_bus(
43
+ defined?(Easyop.config) && Easyop.config.respond_to?(:event_bus) && Easyop.config.event_bus ||
44
+ :memory
45
+ )
46
+ end
47
+ end
48
+
49
+ # Register a handler operation as a subscriber for +pattern+.
50
+ #
51
+ # Called automatically by the EventHandlers plugin when `on` is declared.
52
+ #
53
+ # @param pattern [String, Regexp] event name or glob
54
+ # @param handler_class [Class] operation class to invoke
55
+ # @param async [Boolean] use call_async (requires Async plugin)
56
+ # @param options [Hash] e.g. queue: "low"
57
+ def register_handler(pattern:, handler_class:, async: false, **options)
58
+ entry = { pattern: pattern, handler_class: handler_class,
59
+ async: async, options: options }
60
+
61
+ @mutex.synchronize { _subscriptions << entry }
62
+
63
+ bus.subscribe(pattern) { |event| _dispatch(event, entry) }
64
+ end
65
+
66
+ # Returns a copy of all registered handler entries (for introspection).
67
+ #
68
+ # @return [Array<Hash>]
69
+ def subscriptions
70
+ @mutex.synchronize { _subscriptions.dup }
71
+ end
72
+
73
+ # Reset the registry: drop all subscriptions and replace the bus with a
74
+ # fresh Memory instance. Intended for use in tests (called in before/after hooks).
75
+ def reset!
76
+ @mutex.synchronize do
77
+ @bus = nil
78
+ @subscriptions = nil
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def _subscriptions
85
+ @subscriptions ||= []
86
+ end
87
+
88
+ # @api private — exposed for specs
89
+ def _dispatch(event, entry)
90
+ handler_class = entry[:handler_class]
91
+
92
+ if entry[:async] && handler_class.respond_to?(:call_async)
93
+ # Serialize Event to a plain hash for ActiveJob compatibility.
94
+ attrs = event.payload.merge(event_data: event.to_h)
95
+ queue = entry[:options][:queue]
96
+ handler_class.call_async(attrs, **(queue ? { queue: queue } : {}))
97
+ else
98
+ # Sync: pass Event object directly — ctx.event is the live Event instance.
99
+ handler_class.call(event: event, **event.payload)
100
+ end
101
+ rescue StandardError
102
+ # Handler failures must not propagate back to the publisher.
103
+ end
104
+
105
+ def _resolve_bus(bus_or_symbol)
106
+ case bus_or_symbol
107
+ when :memory
108
+ Bus::Memory.new
109
+ when :active_support
110
+ Bus::ActiveSupportNotifications.new
111
+ when Bus::Base
112
+ bus_or_symbol
113
+ when nil
114
+ Bus::Memory.new
115
+ else
116
+ if bus_or_symbol.respond_to?(:publish) && bus_or_symbol.respond_to?(:subscribe)
117
+ Bus::Custom.new(bus_or_symbol)
118
+ else
119
+ raise ArgumentError,
120
+ "Unknown bus: #{bus_or_symbol.inspect}. " \
121
+ "Use :memory, :active_support, or a bus adapter instance."
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Plugins
5
+ # Subscribes an operation class to handle domain events.
6
+ #
7
+ # The handler operation receives the event payload merged into ctx, plus
8
+ # ctx.event (the Easyop::Events::Event object itself for sync dispatch).
9
+ #
10
+ # Basic usage:
11
+ #
12
+ # class SendOrderConfirmation < ApplicationOperation
13
+ # plugin Easyop::Plugins::EventHandlers
14
+ #
15
+ # on "order.placed"
16
+ #
17
+ # def call
18
+ # event = ctx.event # Easyop::Events::Event
19
+ # order_id = ctx.order_id # payload keys merged into ctx
20
+ # OrderMailer.confirm(order_id).deliver_later
21
+ # end
22
+ # end
23
+ #
24
+ # Async dispatch (requires Easyop::Plugins::Async also installed):
25
+ #
26
+ # class IndexOrder < ApplicationOperation
27
+ # plugin Easyop::Plugins::Async, queue: "indexing"
28
+ # plugin Easyop::Plugins::EventHandlers
29
+ #
30
+ # on "order.*", async: true
31
+ # on "inventory.**", async: true, queue: "low"
32
+ #
33
+ # def call
34
+ # # For async dispatch ctx.event_data is a Hash (serialized for ActiveJob).
35
+ # # Reconstruct if needed: Easyop::Events::Event.new(**ctx.event_data)
36
+ # SearchIndex.reindex(ctx.order_id)
37
+ # end
38
+ # end
39
+ #
40
+ # Wildcard patterns:
41
+ # "order.*" — matches order.placed, order.shipped (not order.payment.failed)
42
+ # "warehouse.**" — matches warehouse.stock.updated, warehouse.zone.moved, etc.
43
+ module EventHandlers
44
+ def self.install(base, **_options)
45
+ base.extend(ClassMethods)
46
+ end
47
+
48
+ module ClassMethods
49
+ # Subscribe this operation to events matching +pattern+.
50
+ #
51
+ # Registration happens at class-load time and is bound to the bus that is
52
+ # active when the class is evaluated. Configure the bus before loading
53
+ # handler classes (e.g. in a Rails initializer that runs before autoloading).
54
+ #
55
+ # @param pattern [String, Regexp] event name or glob
56
+ # @param async [Boolean] enqueue via call_async (requires Async plugin)
57
+ # @param options [Hash] e.g. queue: "low" (overrides Async default)
58
+ def on(pattern, async: false, **options)
59
+ _event_handler_registrations << { pattern: pattern, async: async, options: options }
60
+
61
+ Easyop::Events::Registry.register_handler(
62
+ pattern: pattern,
63
+ handler_class: self,
64
+ async: async,
65
+ **options
66
+ )
67
+ end
68
+
69
+ # @api private — list of registrations declared on this class
70
+ def _event_handler_registrations
71
+ @_event_handler_registrations ||= []
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Plugins
5
+ # Emits domain events after an operation completes.
6
+ #
7
+ # Install on a base operation class to inherit into all subclasses:
8
+ #
9
+ # class ApplicationOperation
10
+ # include Easyop::Operation
11
+ # plugin Easyop::Plugins::Events
12
+ # end
13
+ #
14
+ # Then declare events on individual operations:
15
+ #
16
+ # class PlaceOrder < ApplicationOperation
17
+ # emits "order.placed", on: :success, payload: [:order_id, :total]
18
+ # emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
19
+ # emits "order.attempted", on: :always
20
+ #
21
+ # def call
22
+ # ctx.order_id = Order.create!(ctx.to_h).id
23
+ # end
24
+ # end
25
+ #
26
+ # Plugin options:
27
+ # bus: [Bus::Base, nil] per-class bus override (default: global registry bus)
28
+ # metadata: [Hash, Proc, nil] extra metadata merged into every event from this class
29
+ #
30
+ # `emits` DSL options:
31
+ # on: [:success (default), :failure, :always]
32
+ # payload: [Proc, Array, nil] Proc receives ctx; Array slices ctx keys; nil = full ctx.to_h
33
+ # guard: [Proc, nil] extra condition — event only fires if truthy
34
+ module Events
35
+ def self.install(base, bus: nil, metadata: nil, **_options)
36
+ base.extend(ClassMethods)
37
+ base.prepend(RunWrapper)
38
+ base.instance_variable_set(:@_events_bus, bus)
39
+ base.instance_variable_set(:@_events_metadata, metadata)
40
+ end
41
+
42
+ module ClassMethods
43
+ # Declare an event this operation emits after execution.
44
+ #
45
+ # @example
46
+ # emits "order.placed", on: :success, payload: [:order_id]
47
+ # emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
48
+ #
49
+ # @param name [String]
50
+ # @param on [Symbol] :success (default), :failure, or :always
51
+ # @param payload [Proc, Array, nil]
52
+ # @param guard [Proc, nil] optional condition — fires only when truthy
53
+ def emits(name, on: :success, payload: nil, guard: nil)
54
+ _emitted_events << { name: name.to_s, on: on, payload: payload, guard: guard }
55
+ end
56
+
57
+ # @api private — inheritable list of emits declarations
58
+ def _emitted_events
59
+ @_emitted_events ||= _inherited_emitted_events
60
+ end
61
+
62
+ # @api private — bus for this class (falls back to superclass, then global registry)
63
+ def _events_bus
64
+ if instance_variable_defined?(:@_events_bus)
65
+ @_events_bus
66
+ elsif superclass.respond_to?(:_events_bus)
67
+ superclass._events_bus
68
+ end
69
+ end
70
+
71
+ # @api private — metadata for this class (falls back to superclass)
72
+ def _events_metadata
73
+ if instance_variable_defined?(:@_events_metadata)
74
+ @_events_metadata
75
+ elsif superclass.respond_to?(:_events_metadata)
76
+ superclass._events_metadata
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def _inherited_emitted_events
83
+ superclass.respond_to?(:_emitted_events) ? superclass._emitted_events.dup : []
84
+ end
85
+ end
86
+
87
+ module RunWrapper
88
+ # Wraps _easyop_run to publish declared events in an ensure block.
89
+ # Events fire AFTER the operation completes — success, failure, or exception.
90
+ def _easyop_run(ctx, raise_on_failure:)
91
+ super
92
+ ensure
93
+ _events_publish_all(ctx)
94
+ end
95
+
96
+ private
97
+
98
+ def _events_publish_all(ctx)
99
+ declarations = self.class._emitted_events
100
+ return if declarations.empty?
101
+
102
+ bus = self.class._events_bus || Easyop::Events::Registry.bus
103
+
104
+ declarations.each do |decl|
105
+ next unless _events_should_fire?(decl, ctx)
106
+
107
+ event = _events_build_event(decl, ctx)
108
+ bus.publish(event)
109
+ rescue StandardError
110
+ # Individual publish failures must never crash the operation.
111
+ # Log if Rails is available.
112
+ _events_log_warning($ERROR_INFO)
113
+ end
114
+ end
115
+
116
+ def _events_should_fire?(decl, ctx)
117
+ case decl[:on]
118
+ when :success then return false unless ctx.success?
119
+ when :failure then return false unless ctx.failure?
120
+ when :always then nil # always fire
121
+ end
122
+
123
+ guard = decl[:guard]
124
+ guard ? guard.call(ctx) : true
125
+ end
126
+
127
+ def _events_build_event(decl, ctx)
128
+ Easyop::Events::Event.new(
129
+ name: decl[:name],
130
+ payload: _events_extract_payload(decl[:payload], ctx),
131
+ metadata: _events_build_metadata(ctx),
132
+ source: self.class.name
133
+ )
134
+ end
135
+
136
+ def _events_extract_payload(payload_spec, ctx)
137
+ case payload_spec
138
+ when Proc then payload_spec.call(ctx)
139
+ when Array then ctx.slice(*payload_spec)
140
+ when nil then ctx.to_h
141
+ else payload_spec
142
+ end
143
+ end
144
+
145
+ def _events_build_metadata(ctx)
146
+ meta = self.class._events_metadata
147
+ case meta
148
+ when Proc then meta.call(ctx)
149
+ when Hash then meta.dup
150
+ else {}
151
+ end
152
+ end
153
+
154
+ def _events_log_warning(err)
155
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
156
+
157
+ Rails.logger.warn "[EasyOp::Events] Failed to publish event: #{err.message}"
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -1,3 +1,3 @@
1
1
  module Easyop
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
data/lib/easyop.rb CHANGED
@@ -18,6 +18,17 @@ require_relative "easyop/flow"
18
18
  # require "easyop/plugins/recording"
19
19
  # require "easyop/plugins/async"
20
20
 
21
+ # Domain event plugins — require together or individually:
22
+ # require "easyop/events/event"
23
+ # require "easyop/events/bus"
24
+ # require "easyop/events/bus/memory"
25
+ # require "easyop/events/bus/active_support_notifications"
26
+ # require "easyop/events/bus/custom"
27
+ # require "easyop/events/bus/adapter" # inherit this to build a custom bus
28
+ # require "easyop/events/registry"
29
+ # require "easyop/plugins/events"
30
+ # require "easyop/plugins/event_handlers"
31
+
21
32
  module Easyop
22
33
  # Convenience: inherit from this instead of including Easyop::Operation
23
34
  # when you want a common base class for all your operations.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easyop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pawel Niemczyk
@@ -53,12 +53,21 @@ files:
53
53
  - lib/easyop.rb
54
54
  - lib/easyop/configuration.rb
55
55
  - lib/easyop/ctx.rb
56
+ - lib/easyop/events/bus.rb
57
+ - lib/easyop/events/bus/active_support_notifications.rb
58
+ - lib/easyop/events/bus/adapter.rb
59
+ - lib/easyop/events/bus/custom.rb
60
+ - lib/easyop/events/bus/memory.rb
61
+ - lib/easyop/events/event.rb
62
+ - lib/easyop/events/registry.rb
56
63
  - lib/easyop/flow.rb
57
64
  - lib/easyop/flow_builder.rb
58
65
  - lib/easyop/hooks.rb
59
66
  - lib/easyop/operation.rb
60
67
  - lib/easyop/plugins/async.rb
61
68
  - lib/easyop/plugins/base.rb
69
+ - lib/easyop/plugins/event_handlers.rb
70
+ - lib/easyop/plugins/events.rb
62
71
  - lib/easyop/plugins/instrumentation.rb
63
72
  - lib/easyop/plugins/recording.rb
64
73
  - lib/easyop/plugins/transactional.rb