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 +4 -4
- data/CHANGELOG.md +159 -3
- data/README.md +287 -0
- data/lib/easyop/configuration.rb +8 -0
- data/lib/easyop/events/bus/active_support_notifications.rb +82 -0
- data/lib/easyop/events/bus/adapter.rb +119 -0
- data/lib/easyop/events/bus/custom.rb +51 -0
- data/lib/easyop/events/bus/memory.rb +64 -0
- data/lib/easyop/events/bus.rb +66 -0
- data/lib/easyop/events/event.rb +44 -0
- data/lib/easyop/events/registry.rb +128 -0
- data/lib/easyop/plugins/async.rb +12 -0
- data/lib/easyop/plugins/event_handlers.rb +76 -0
- data/lib/easyop/plugins/events.rb +162 -0
- data/lib/easyop/version.rb +1 -1
- data/lib/easyop.rb +11 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c77d9aa5925ea30b3de85fc74618f355ee3498223f79d6184cda21e95574b8d
|
|
4
|
+
data.tar.gz: 8bd775d8fa095a50b46aafeedec78f2046d54005b6316a1f22ae02b5657c361c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
47
|
-
[0.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
|
+
```
|
data/lib/easyop/configuration.rb
CHANGED
|
@@ -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
|