easyop 0.1.2 → 0.1.4

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: 2b880af1b623158d3acfac130198bd96ce8c268195fd073554283738a880a45a
4
+ data.tar.gz: 0c4f5554b456ac252b13734d0964155e2a80909e81946e28f89c529cc60f9081
5
5
  SHA512:
6
- metadata.gz: 52fc250d99c11808462344afb74603081aa2dd35ca4f4d16ec263d19b3c098bba77ddcdda7963793483a9bfc0b04bd2cf63a70700c9756e0fb6b4610229ecc9f
7
- data.tar.gz: 778348d6dc863bdda5816a32738ae2262c3948eb3711090b0a4412bc0678a9dc9b7c87e48cd937c3f1842e492f5be787c252c42f8ae9955191794cc9200dfc1e
6
+ metadata.gz: c408d6d42a357ba1a6df4da21e13a7b0498d36fa4252e86c68aff289e8843d94d056c430f654f023e6d801ee650fc1bc4c400cb6d058a6b209129e1fbda644fd
7
+ data.tar.gz: e89087c84c8fbc77abe96b6713a144dd34b78c2cfd35066f28a14e37516f0e7ae8b0f0cbbcfe0ca9eb9de646c7890552b64d7d2848c9976ff6f51637531a4a8a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,199 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.4] — 2026-04-14
11
+
12
+ ### Added
13
+
14
+ - **Flow-tracing forwarding in `Easyop::Flow`** — `CallBehavior#call` now automatically sets the `__recording_parent_*` ctx keys before running steps, so every child operation's log entry carries the flow class as its `parent_operation_name` and `parent_reference_id`. This works even when Recording is not installed on the flow class itself (bare `include Easyop::Flow`). When Recording IS installed (recommended: inherit from ApplicationOperation and add `transactional false`), the flow appears in the log as the root entry and RunWrapper handles the ctx setup — no double-setup occurs.
15
+
16
+ ```ruby
17
+ # Bare flow — Recording on steps only; flow itself is not recorded but
18
+ # steps correctly show parent_operation_name: "Flows::Checkout"
19
+ class Flows::Checkout
20
+ include Easyop::Flow
21
+ flow Orders::CreateOrder, Orders::ProcessPayment
22
+ end
23
+
24
+ # Recommended — flow is recorded as root, steps as children:
25
+ class Flows::Checkout < ApplicationOperation
26
+ include Easyop::Flow
27
+ transactional false # steps manage their own transactions
28
+ flow Orders::CreateOrder, Orders::ProcessPayment
29
+ end
30
+ ```
31
+
32
+ Result in operation_logs:
33
+ ```
34
+ Flows::Checkout root=aaa ref=bbb parent=nil
35
+ Orders::CreateOrder root=aaa ref=ccc parent=Flows::Checkout/bbb
36
+ Orders::ProcessPayment root=aaa ref=ddd parent=Flows::Checkout/bbb
37
+ ```
38
+
39
+ - **`record_result` DSL for `Easyop::Plugins::Recording`** — selectively persist ctx output data into a new optional `result_data :text` column (stored as JSON). Supports three forms:
40
+
41
+ ```ruby
42
+ # Attrs form — one or more ctx keys
43
+ record_result attrs: :invoice_id
44
+ record_result attrs: [:invoice_id, :total]
45
+
46
+ # Block form — custom extraction
47
+ record_result { |ctx| { total: ctx.total, items: ctx.items.count } }
48
+
49
+ # Symbol form — delegates to a private instance method
50
+ record_result :build_result
51
+ ```
52
+
53
+ Plugin-level default (inherited by all subclasses):
54
+ ```ruby
55
+ plugin Easyop::Plugins::Recording, model: OperationLog, record_result: { attrs: :metadata }
56
+ ```
57
+
58
+ Class-level `record_result` overrides the plugin-level default. Missing ctx keys produce `nil` (no error). ActiveRecord objects are serialized as `{ id:, class: }`. Serialization errors are swallowed. The `result_data` column is silently skipped when absent from the model table — fully backward-compatible.
59
+
60
+ ### Fixed
61
+
62
+ - **`Easyop::Schema` type-mismatch warning suppressed when `$VERBOSE` is `nil`** — `warn` is a no-op in Ruby when `$VERBOSE` is `nil` (e.g. when running under certain test setups or with `-W0`). Changed to `$stderr.puts` so the `[EasyOp]` type-mismatch message always reaches stderr regardless of Ruby's verbosity flag.
63
+
64
+ - **`Easyop::Schema` spec `strict_types` contamination across examples** — Setting `strict_types = true` inside an example without a corresponding teardown left the global config dirty for later examples. Added `after(:each) { Easyop.reset_config! }` at the top-level `RSpec.describe` so every example begins with a clean configuration.
65
+
66
+ - **`Easyop::Plugins::Instrumentation` flaky duration assertion** — The `:duration` payload is computed as `(elapsed_ms).round(2)`, which rounds to `0.0` for operations completing in under 0.005 ms. Relaxed the spec assertion from `be > 0` to `be >= 0` to reflect that a rounded-to-zero duration is a valid measurement, not an error.
67
+
68
+ ## [0.1.3] — 2026-04-14
69
+
70
+ ### Added
71
+
72
+ - **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'`).
73
+
74
+ Coverage across 258 tests, 360 assertions:
75
+
76
+ | Area | Files |
77
+ |------|-------|
78
+ | Core | `ctx_test`, `operation_test`, `hooks_test`, `rescuable_test`, `schema_test`, `skip_test`, `flow_test`, `flow_builder_test` |
79
+ | 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` |
80
+ | Plugins | `plugins/base_test`, `plugins/recording_test`, `plugins/instrumentation_test`, `plugins/async_test`, `plugins/transactional_test`, `plugins/events_test`, `plugins/event_handlers_test` |
81
+
82
+ 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.
83
+
84
+ - **`Rakefile`** — adds a `test` task (Minitest) as the default Rake task:
85
+
86
+ ```ruby
87
+ bundle exec rake test
88
+ # or simply:
89
+ bundle exec rake
90
+ ```
91
+
92
+ - **`rake` gem** added to the `development/test` group in `Gemfile`.
93
+
94
+ - **`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`:
95
+
96
+ - `_safe_invoke(handler, event)` — calls `handler.call(event)` and rescues `StandardError`, so one broken subscriber never prevents others from running
97
+ - `_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)
98
+
99
+ ```ruby
100
+ require "easyop/events/bus/adapter"
101
+
102
+ # Decorator: wrap any inner bus and add structured logging
103
+ class LoggingBus < Easyop::Events::Bus::Adapter
104
+ def initialize(inner = Easyop::Events::Bus::Memory.new)
105
+ super(); @inner = inner
106
+ end
107
+
108
+ def publish(event)
109
+ Rails.logger.info "[bus] #{event.name} payload=#{event.payload}"
110
+ @inner.publish(event)
111
+ end
112
+
113
+ def subscribe(pattern, &block) = @inner.subscribe(pattern, &block)
114
+ def unsubscribe(handle) = @inner.unsubscribe(handle)
115
+ end
116
+
117
+ Easyop::Events::Registry.bus = LoggingBus.new
118
+
119
+ # Full external broker example — RabbitMQ via Bunny gem:
120
+ class RabbitBus < Easyop::Events::Bus::Adapter
121
+ EXCHANGE_NAME = "easyop.events"
122
+ def initialize(url = ENV.fetch("AMQP_URL")) = (super(); @url = url)
123
+ def publish(event)
124
+ exchange.publish(event.to_h.to_json, routing_key: event.name)
125
+ end
126
+ def subscribe(pattern, &block)
127
+ q = channel.queue("", exclusive: true, auto_delete: true)
128
+ q.bind(exchange, routing_key: pattern.gsub("**", "#"))
129
+ q.subscribe { |_, _, body| _safe_invoke(block, decode(body)) }
130
+ end
131
+ private
132
+ def decode(body) = Easyop::Events::Event.new(**JSON.parse(body, symbolize_names: true))
133
+ def connection = @conn ||= Bunny.new(@url).tap(&:start)
134
+ def channel = @ch ||= connection.create_channel
135
+ def exchange = @exch ||= channel.topic(EXCHANGE_NAME, durable: true)
136
+ end
137
+ ```
138
+
139
+ - **`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:
140
+
141
+ ```ruby
142
+ class PlaceOrder < ApplicationOperation
143
+ plugin Easyop::Plugins::Events
144
+
145
+ emits "order.placed", on: :success, payload: [:order_id, :total]
146
+ emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
147
+ emits "order.attempted", on: :always
148
+ end
149
+ ```
150
+
151
+ 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.
152
+
153
+ - **`Plugins::EventHandlers`** — a new subscriber plugin that wires an operation as a domain event handler:
154
+
155
+ ```ruby
156
+ class SendConfirmation < ApplicationOperation
157
+ plugin Easyop::Plugins::EventHandlers
158
+
159
+ on "order.placed"
160
+
161
+ def call
162
+ OrderMailer.confirm(ctx.order_id).deliver_later
163
+ end
164
+ end
165
+
166
+ # Async dispatch (requires Plugins::Async also installed):
167
+ class IndexOrder < ApplicationOperation
168
+ plugin Easyop::Plugins::Async, queue: "indexing"
169
+ plugin Easyop::Plugins::EventHandlers
170
+
171
+ on "order.*", async: true
172
+ on "inventory.**", async: true, queue: "low"
173
+
174
+ def call
175
+ SearchIndex.reindex(ctx.order_id)
176
+ end
177
+ end
178
+ ```
179
+
180
+ 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.
181
+
182
+ - **`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`.
183
+
184
+ - **`Easyop::Events::Bus`** — pluggable bus adapter system with three built-in adapters:
185
+ - `Bus::Memory` — in-process synchronous bus (default). Thread-safe via Mutex. Supports glob patterns and Regexp subscriptions. Test-friendly: `clear!`, `subscriber_count`.
186
+ - `Bus::ActiveSupportNotifications` — wraps `ActiveSupport::Notifications`. Lazy-checks for the library.
187
+ - `Bus::Custom` — wraps any user object responding to `#publish` and `#subscribe`. Validates the interface at construction time.
188
+
189
+ - **`Easyop::Events::Registry`** — thread-safe global coordination point. Configure once at boot; handler subscriptions are registered against it at class-load time:
190
+
191
+ ```ruby
192
+ # Globally:
193
+ Easyop::Events::Registry.bus = :memory # default
194
+ Easyop::Events::Registry.bus = :active_support
195
+ Easyop::Events::Registry.bus = MyRabbitBus.new # custom adapter
196
+
197
+ # Or via config:
198
+ Easyop.configure { |c| c.event_bus = :active_support }
199
+ ```
200
+
201
+ - **`Easyop::Configuration#event_bus`** — new configuration key. Accepts `:memory`, `:active_support`, or a bus adapter instance.
202
+
10
203
  ## [0.1.2] — 2026-04-13
11
204
 
12
205
  ### Added
@@ -65,7 +258,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
65
258
  - `examples/easyop_test_app/` — full Rails 8 blog application demonstrating all features in real-world code
66
259
  - `examples/usage.rb` — 13 runnable plain-Ruby examples
67
260
 
68
- [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
261
+ [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.4...HEAD
262
+ [0.1.4]: https://github.com/pniemczyk/easyop/compare/v0.1.3...v0.1.4
263
+ [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
data/README.md CHANGED
@@ -428,6 +428,39 @@ class FullCheckout
428
428
  end
429
429
  ```
430
430
 
431
+ ### Recording plugin integration — full call-tree tracing
432
+
433
+ When step operations have the Recording plugin installed, `Easyop::Flow` automatically forwards the parent-tracing ctx so every step's log entry shows the flow as its `parent_operation_name`. All steps and the flow share the same `root_reference_id`.
434
+
435
+ **Bare flow** (Recording only on steps — flow is NOT recorded itself, but steps carry correct parent info):
436
+
437
+ ```ruby
438
+ class ProcessCheckout
439
+ include Easyop::Flow
440
+ flow ValidateCart, ChargePayment, CreateOrder
441
+ end
442
+ ```
443
+
444
+ **Recommended** — inherit from your recorded base class so the **flow itself appears in operation_logs** as the tree root. Add `transactional false` so step-level transactions aren't shadowed by an outer one:
445
+
446
+ ```ruby
447
+ class ProcessCheckout < ApplicationOperation
448
+ include Easyop::Flow
449
+ transactional false # EasyOp handles rollback; each step owns its transaction
450
+
451
+ flow ValidateCart, ChargePayment, CreateOrder
452
+ end
453
+ ```
454
+
455
+ Result in `operation_logs`:
456
+
457
+ ```
458
+ ProcessCheckout root=aaa ref=bbb parent=nil
459
+ ValidateCart root=aaa ref=ccc parent=ProcessCheckout/bbb
460
+ ChargePayment root=aaa ref=ddd parent=ProcessCheckout/bbb
461
+ CreateOrder root=aaa ref=eee parent=ProcessCheckout/bbb
462
+ ```
463
+
431
464
  ---
432
465
 
433
466
  ## `prepare` — Pre-registered Callbacks
@@ -613,6 +646,7 @@ end
613
646
  |---|---|---|
614
647
  | `model:` | required | ActiveRecord class to write logs into |
615
648
  | `record_params:` | `true` | Set `false` to skip serializing ctx params |
649
+ | `record_result:` | `nil` | Plugin-level default for result capture (Hash/Proc/Symbol — see below) |
616
650
 
617
651
  **Required model columns:**
618
652
 
@@ -627,8 +661,91 @@ create_table :operation_logs do |t|
627
661
  end
628
662
  ```
629
663
 
664
+ **Optional flow-tracing columns:**
665
+
666
+ Add these columns to reconstruct the full call tree when nested flows run. They are populated automatically when present — missing columns are silently skipped (backward-compatible):
667
+
668
+ ```ruby
669
+ add_column :operation_logs, :root_reference_id, :string
670
+ add_column :operation_logs, :reference_id, :string
671
+ add_column :operation_logs, :parent_operation_name, :string
672
+ add_column :operation_logs, :parent_reference_id, :string
673
+
674
+ add_index :operation_logs, :root_reference_id
675
+ add_index :operation_logs, :reference_id, unique: true
676
+ add_index :operation_logs, :parent_reference_id
677
+ ```
678
+
679
+ All operations triggered by a single top-level call share the same `root_reference_id`. The `parent_operation_name` and `parent_reference_id` columns link each operation to its direct caller. `Easyop::Flow` automatically forwards these ctx keys to child steps — see the [Flow section](#flow--composing-operations) for how to make the flow itself appear as the tree root. Example (flow with nested steps):
680
+
681
+ ```
682
+ FullCheckout root=aaa ref=bbb parent=nil
683
+ AuthAndValidate root=aaa ref=ccc parent=FullCheckout/bbb
684
+ AuthUser root=aaa ref=ddd parent=AuthAndValidate/ccc
685
+ ProcessPayment root=aaa ref=eee parent=FullCheckout/bbb
686
+ ```
687
+
688
+ Useful model helpers:
689
+
690
+ ```ruby
691
+ scope :for_tree, ->(id) { where(root_reference_id: id).order(:performed_at) }
692
+ def root?; parent_reference_id.nil?; end
693
+
694
+ # Fetch the entire execution tree for one top-level call:
695
+ root_log = OperationLog.find_by(operation_name: "FullCheckout", parent_reference_id: nil)
696
+ OperationLog.for_tree(root_log.root_reference_id)
697
+ ```
698
+
630
699
  The plugin automatically scrubs these keys from `params_data` before persisting: `:password`, `:password_confirmation`, `:token`, `:secret`, `:api_key`. ActiveRecord objects are serialized as `{ id:, class: }` rather than their full representation.
631
700
 
701
+ **`record_result` DSL — capture output data:**
702
+
703
+ Add an optional `result_data :text` column to persist selected ctx values after the operation runs:
704
+
705
+ ```ruby
706
+ add_column :operation_logs, :result_data, :text # stored as JSON
707
+ ```
708
+
709
+ Then declare what to record using the `record_result` DSL (three forms):
710
+
711
+ ```ruby
712
+ # Attrs form — one or more ctx keys
713
+ class PlaceOrder < ApplicationOperation
714
+ record_result attrs: :order_id
715
+ end
716
+
717
+ class ProcessPayment < ApplicationOperation
718
+ record_result attrs: [:charge_id, :amount_cents]
719
+ end
720
+
721
+ # Block form — custom extraction
722
+ class GenerateReport < ApplicationOperation
723
+ record_result { |ctx| { rows: ctx.rows.count, format: ctx.format } }
724
+ end
725
+
726
+ # Symbol form — delegates to a private instance method
727
+ class BuildInvoice < ApplicationOperation
728
+ record_result :build_result
729
+
730
+ private
731
+
732
+ def build_result
733
+ { invoice_id: ctx.invoice.id, total: ctx.total }
734
+ end
735
+ end
736
+ ```
737
+
738
+ Set a plugin-level default inherited by all subclasses:
739
+
740
+ ```ruby
741
+ plugin Easyop::Plugins::Recording, model: OperationLog,
742
+ record_result: { attrs: :metadata }
743
+ # or: record_result: ->(ctx) { { id: ctx.record_id } }
744
+ # or: record_result: :build_result
745
+ ```
746
+
747
+ Class-level `record_result` overrides the plugin-level default. Missing ctx keys produce `nil` — no error. The `result_data` column is silently skipped when absent from the model table — fully backward-compatible.
748
+
632
749
  **Opt out per class:**
633
750
 
634
751
  ```ruby
@@ -699,6 +816,198 @@ The plugin defines `Easyop::Plugins::Async::Job` lazily (on first call to `.call
699
816
 
700
817
  ---
701
818
 
819
+ ### Plugin: Events
820
+
821
+ 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.
822
+
823
+ ```ruby
824
+ require "easyop/events/event"
825
+ require "easyop/events/bus"
826
+ require "easyop/events/bus/memory"
827
+ require "easyop/events/registry"
828
+ require "easyop/plugins/events"
829
+ require "easyop/plugins/event_handlers"
830
+
831
+ class PlaceOrder < ApplicationOperation
832
+ plugin Easyop::Plugins::Events
833
+
834
+ emits "order.placed", on: :success, payload: [:order_id, :total]
835
+ emits "order.failed", on: :failure, payload: ->(ctx) { { error: ctx.error } }
836
+ emits "order.attempted", on: :always
837
+
838
+ def call
839
+ ctx.order_id = Order.create!(ctx.to_h).id
840
+ end
841
+ end
842
+ ```
843
+
844
+ **`emits` DSL options:**
845
+
846
+ | Option | Values | Default | Description |
847
+ |---|---|---|---|
848
+ | `on:` | `:success`, `:failure`, `:always` | `:success` | When to fire |
849
+ | `payload:` | `Proc`, `Array`, `nil` | `nil` (full ctx) | Proc receives ctx; Array slices ctx keys |
850
+ | `guard:` | `Proc`, `nil` | `nil` | Extra condition — fires only when truthy |
851
+
852
+ 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.
853
+
854
+ ---
855
+
856
+ ### Plugin: EventHandlers
857
+
858
+ 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.
859
+
860
+ ```ruby
861
+ class SendConfirmation < ApplicationOperation
862
+ plugin Easyop::Plugins::EventHandlers
863
+
864
+ on "order.placed"
865
+
866
+ def call
867
+ event = ctx.event # Easyop::Events::Event
868
+ order_id = ctx.order_id # payload keys merged into ctx
869
+ OrderMailer.confirm(order_id).deliver_later
870
+ end
871
+ end
872
+ ```
873
+
874
+ **Async dispatch** (requires `Plugins::Async` also installed):
875
+
876
+ ```ruby
877
+ class IndexOrder < ApplicationOperation
878
+ plugin Easyop::Plugins::Async, queue: "indexing"
879
+ plugin Easyop::Plugins::EventHandlers
880
+
881
+ on "order.*", async: true
882
+ on "inventory.**", async: true, queue: "low"
883
+
884
+ def call
885
+ # ctx.event_data is a Hash when dispatched async (serializable for ActiveJob)
886
+ SearchIndex.reindex(ctx.order_id)
887
+ end
888
+ end
889
+ ```
890
+
891
+ **Glob patterns:**
892
+ - `"order.*"` — matches within one dot-segment (`order.placed`, `order.shipped`)
893
+ - `"warehouse.**"` — matches across segments (`warehouse.stock.updated`, `warehouse.zone.moved`)
894
+ - Plain strings match exactly; `Regexp` is also accepted
895
+
896
+ Registration happens at class-load time. Configure the bus **before** loading handler classes.
897
+
898
+ ---
899
+
900
+ ### Events Bus — Configurable Transport
901
+
902
+ The bus adapter controls how events are delivered. Configure it once at boot:
903
+
904
+ ```ruby
905
+ # Default — in-process synchronous (great for tests and simple setups)
906
+ Easyop::Events::Registry.bus = :memory
907
+
908
+ # ActiveSupport::Notifications (integrates with Rails instrumentation)
909
+ Easyop::Events::Registry.bus = :active_support
910
+
911
+ # Any custom adapter (RabbitMQ, Kafka, Redis Pub/Sub…)
912
+ Easyop::Events::Registry.bus = MyRabbitBus.new
913
+
914
+ # Or via config block:
915
+ Easyop.configure { |c| c.event_bus = :active_support }
916
+ ```
917
+
918
+ **Built-in adapters:**
919
+
920
+ | Adapter | Class | Notes |
921
+ |---|---|---|
922
+ | Memory | `Easyop::Events::Bus::Memory` | Default. Thread-safe, in-process, synchronous. |
923
+ | ActiveSupport | `Easyop::Events::Bus::ActiveSupportNotifications` | Wraps `AS::Notifications`. Requires `activesupport`. |
924
+ | Custom | `Easyop::Events::Bus::Custom` | Wraps any object with `#publish` and `#subscribe`. |
925
+
926
+ **Building a custom bus — two approaches:**
927
+
928
+ **Option A — subclass `Bus::Adapter`** (recommended for real transports). Inherits glob helpers, `_safe_invoke`, and `_compile_pattern`:
929
+
930
+ ```ruby
931
+ require "easyop/events/bus/adapter"
932
+
933
+ # Decorator: wraps any inner bus and adds structured logging
934
+ class LoggingBus < Easyop::Events::Bus::Adapter
935
+ def initialize(inner = Easyop::Events::Bus::Memory.new)
936
+ super()
937
+ @inner = inner
938
+ end
939
+
940
+ def publish(event)
941
+ Rails.logger.info "[bus:publish] #{event.name} payload=#{event.payload}"
942
+ @inner.publish(event)
943
+ end
944
+
945
+ def subscribe(pattern, &block) = @inner.subscribe(pattern, &block)
946
+ def unsubscribe(handle) = @inner.unsubscribe(handle)
947
+ end
948
+
949
+ Easyop::Events::Registry.bus = LoggingBus.new
950
+
951
+ # Full RabbitMQ example (Bunny gem) — uses _safe_invoke for handler safety:
952
+ class RabbitBus < Easyop::Events::Bus::Adapter
953
+ EXCHANGE_NAME = "easyop.events"
954
+
955
+ def initialize(url = ENV.fetch("AMQP_URL", "amqp://localhost"))
956
+ super()
957
+ @url = url; @mutex = Mutex.new; @handles = {}
958
+ end
959
+
960
+ def publish(event)
961
+ exchange.publish(event.to_h.to_json,
962
+ routing_key: event.name, content_type: "application/json")
963
+ end
964
+
965
+ def subscribe(pattern, &block)
966
+ q = channel.queue("", exclusive: true, auto_delete: true)
967
+ q.bind(exchange, routing_key: _to_amqp(pattern))
968
+ consumer = q.subscribe { |_, _, body| _safe_invoke(block, decode(body)) }
969
+ handle = Object.new
970
+ @mutex.synchronize { @handles[handle.object_id] = { queue: q, consumer: consumer } }
971
+ handle
972
+ end
973
+
974
+ def unsubscribe(handle)
975
+ @mutex.synchronize do
976
+ e = @handles.delete(handle.object_id); return unless e
977
+ e[:consumer].cancel; e[:queue].delete
978
+ end
979
+ end
980
+
981
+ def disconnect
982
+ @mutex.synchronize { @connection&.close; @connection = @channel = @exchange = nil }
983
+ end
984
+
985
+ private
986
+
987
+ def _to_amqp(p) = p.is_a?(Regexp) ? p.source : p.gsub("**", "#")
988
+ def decode(body) = Easyop::Events::Event.new(**JSON.parse(body, symbolize_names: true))
989
+ def connection = @connection ||= Bunny.new(@url, recover_from_connection_close: true).tap(&:start)
990
+ def channel = @channel ||= connection.create_channel
991
+ def exchange = @exchange ||= channel.topic(EXCHANGE_NAME, durable: true)
992
+ end
993
+
994
+ Easyop::Events::Registry.bus = RabbitBus.new
995
+ at_exit { Easyop::Events::Registry.bus.disconnect }
996
+ ```
997
+
998
+ **Option B — duck-typed object** (no subclassing). Pass any object with `#publish` and `#subscribe`; Registry auto-wraps it in `Bus::Custom`:
999
+
1000
+ ```ruby
1001
+ class MyKafkaBus
1002
+ def publish(event) = Kafka.produce(event.name, event.to_h.to_json)
1003
+ def subscribe(pattern, &block) = Kafka.subscribe(pattern) { |msg| block.call(decode(msg)) }
1004
+ end
1005
+
1006
+ Easyop::Events::Registry.bus = MyKafkaBus.new
1007
+ ```
1008
+
1009
+ ---
1010
+
702
1011
  ### Plugin: Transactional
703
1012
 
704
1013
  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 +1431,84 @@ See the **[AI Tools docs page](https://pniemczyk.github.io/easyop/ai-tools.html)
1122
1431
  | `Easyop::Plugins::Async` | `easyop/plugins/async` | Adds `.call_async` via ActiveJob with AR object serialization |
1123
1432
  | `Easyop::Plugins::Async::Job` | (created lazily) | The ActiveJob class that deserializes and runs the operation |
1124
1433
  | `Easyop::Plugins::Transactional` | `easyop/plugins/transactional` | Wraps operation in an AR/Sequel transaction; `transactional false` to opt out |
1434
+ | `Easyop::Plugins::Events` | `easyop/plugins/events` | Emits domain events after execution; `emits` DSL with `on:`, `payload:`, `guard:` |
1435
+ | `Easyop::Plugins::EventHandlers` | `easyop/plugins/event_handlers` | Subscribes an operation to handle domain events; `on` DSL with glob patterns |
1436
+
1437
+ ### Domain Events Infrastructure
1438
+
1439
+ | Class/Module | Require | Description |
1440
+ |---|---|---|
1441
+ | `Easyop::Events::Event` | `easyop/events/event` | Immutable frozen value object: `name`, `payload`, `metadata`, `timestamp`, `source` |
1442
+ | `Easyop::Events::Bus::Base` | `easyop/events/bus` | Abstract adapter interface (`publish`, `subscribe`, `unsubscribe`) and glob helpers |
1443
+ | `Easyop::Events::Bus::Adapter` | `easyop/events/bus/adapter` | **Inheritable base for custom buses.** Adds `_safe_invoke` + `_compile_pattern` (memoized). Subclass this. |
1444
+ | `Easyop::Events::Bus::Memory` | `easyop/events/bus/memory` | In-process synchronous bus (default). Thread-safe. |
1445
+ | `Easyop::Events::Bus::ActiveSupportNotifications` | `easyop/events/bus/active_support_notifications` | `ActiveSupport::Notifications` adapter |
1446
+ | `Easyop::Events::Bus::Custom` | `easyop/events/bus/custom` | Wraps any user-provided bus object (duck-typed, no subclassing needed) |
1447
+ | `Easyop::Events::Registry` | `easyop/events/registry` | Global bus holder + handler subscription registry |
1448
+
1449
+ ---
1450
+
1451
+ ## Releasing a New Version
1452
+
1453
+ Follow these steps to bump the version, update the changelog, and publish a tagged release.
1454
+
1455
+ ### 1. Bump the version number
1456
+
1457
+ Edit `lib/easyop/version.rb` and increment the version string following [Semantic Versioning](https://semver.org/):
1458
+
1459
+ ```ruby
1460
+ # lib/easyop/version.rb
1461
+ module Easyop
1462
+ VERSION = "0.1.4" # was 0.1.3
1463
+ end
1464
+ ```
1465
+
1466
+ ### 2. Update the changelog
1467
+
1468
+ In `CHANGELOG.md`, move everything under `[Unreleased]` into a new versioned section:
1469
+
1470
+ ```markdown
1471
+ ## [Unreleased]
1472
+
1473
+ ## [0.1.4] — YYYY-MM-DD # ← new section
1474
+
1475
+ ### Added
1476
+ - …
1477
+
1478
+ ## [0.1.3] — 2026-04-14
1479
+ ```
1480
+
1481
+ Add a comparison link at the bottom of the file:
1482
+
1483
+ ```markdown
1484
+ [Unreleased]: https://github.com/pniemczyk/easyop/compare/v0.1.4...HEAD
1485
+ [0.1.4]: https://github.com/pniemczyk/easyop/compare/v0.1.3...v0.1.4
1486
+ [0.1.3]: https://github.com/pniemczyk/easyop/compare/v0.1.2...v0.1.3
1487
+ ```
1488
+
1489
+ ### 3. Commit the release changes
1490
+
1491
+ ```bash
1492
+ git add lib/easyop/version.rb CHANGELOG.md
1493
+ git commit -m "Release v0.1.4"
1494
+ ```
1495
+
1496
+ ### 4. Tag the commit
1497
+
1498
+ ```bash
1499
+ git tag -a v0.1.4 -m "Release v0.1.4"
1500
+ ```
1501
+
1502
+ ### 5. Push the commit and tag
1503
+
1504
+ ```bash
1505
+ git push origin master
1506
+ git push origin v0.1.4
1507
+ ```
1508
+
1509
+ ### 6. Build and push the gem (optional)
1510
+
1511
+ ```bash
1512
+ gem build easyop.gemspec
1513
+ gem push easyop-0.1.4.gem
1514
+ ```
@@ -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