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 +4 -4
- data/CHANGELOG.md +196 -4
- data/README.md +390 -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/flow.rb +48 -0
- data/lib/easyop/plugins/event_handlers.rb +76 -0
- data/lib/easyop/plugins/events.rb +162 -0
- data/lib/easyop/plugins/recording.rb +112 -11
- data/lib/easyop/schema.rb +1 -1
- 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: 2b880af1b623158d3acfac130198bd96ce8c268195fd073554283738a880a45a
|
|
4
|
+
data.tar.gz: 0c4f5554b456ac252b13734d0964155e2a80909e81946e28f89c529cc60f9081
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
69
|
-
[0.1.
|
|
70
|
-
[0.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
|
+
```
|
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
|
|