easyop 0.1.0

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.
data/README.md ADDED
@@ -0,0 +1,1089 @@
1
+ # EasyOp
2
+
3
+ [![Docs](https://img.shields.io/badge/docs-pniemczyk.github.io%2Feasyop-blue)](https://pniemczyk.github.io/easyop/)
4
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
+ [![Changelog](https://img.shields.io/badge/changelog-CHANGELOG.md-orange)](CHANGELOG.md)
6
+
7
+ **[📖 Documentation](https://pniemczyk.github.io/easyop/)**  |  **[GitHub](https://github.com/pniemczyk/easyop)**  |  **[Changelog](CHANGELOG.md)**
8
+
9
+ A joyful, opinionated Ruby gem for wrapping business logic in composable operations.
10
+
11
+ ```ruby
12
+ class AuthenticateUser
13
+ include Easyop::Operation
14
+
15
+ def call
16
+ user = User.authenticate(ctx.email, ctx.password)
17
+ ctx.fail!(error: "Invalid credentials") unless user
18
+ ctx.user = user
19
+ end
20
+ end
21
+
22
+ result = AuthenticateUser.call(email: "alice@example.com", password: "hunter2")
23
+ result.success? # => true
24
+ result.user # => #<User ...>
25
+ ```
26
+
27
+ ## Installation
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ gem "easyop"
32
+ ```
33
+
34
+ ```
35
+ bundle install
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ Every operation:
41
+ - includes `Easyop::Operation`
42
+ - defines a `call` method that reads/writes `ctx`
43
+ - returns `ctx` from `.call` — the shared data bag that doubles as the result object
44
+
45
+ ```ruby
46
+ class DoubleNumber
47
+ include Easyop::Operation
48
+
49
+ def call
50
+ ctx.fail!(error: "input must be a number") unless ctx.number.is_a?(Numeric)
51
+ ctx.result = ctx.number * 2
52
+ end
53
+ end
54
+
55
+ result = DoubleNumber.call(number: 21)
56
+ result.success? # => true
57
+ result.result # => 42
58
+
59
+ result = DoubleNumber.call(number: "oops")
60
+ result.failure? # => true
61
+ result.error # => "input must be a number"
62
+ ```
63
+
64
+ ---
65
+
66
+ ## The `ctx` Object
67
+
68
+ `ctx` is the shared data bag — a Hash-backed object with method-style attribute access. It is passed in from the caller and returned as the result.
69
+
70
+ ```ruby
71
+ # Reading
72
+ ctx.email # method access
73
+ ctx[:email] # hash-style access
74
+ ctx.admin? # predicate: !!ctx[:admin]
75
+ ctx.key?(:email) # explicit existence check (true/false)
76
+
77
+ # Writing
78
+ ctx.user = user
79
+ ctx[:user] = user
80
+ ctx.merge!(user: user, token: "abc")
81
+
82
+ # Extracting a subset
83
+ ctx.slice(:name, :email) # => { name: "Alice", email: "alice@example.com" }
84
+ ctx.to_h # => plain Hash copy of all attributes
85
+
86
+ # Status
87
+ ctx.success? # true unless fail! was called
88
+ ctx.ok? # alias for success?
89
+ ctx.failure? # true after fail!
90
+ ctx.failed? # alias for failure?
91
+
92
+ # Fail fast
93
+ ctx.fail! # mark failed
94
+ ctx.fail!(error: "Bad input") # merge attrs then fail
95
+ ctx.fail!(error: "Validation failed", errors: { email: "is blank" })
96
+
97
+ # Error helpers
98
+ ctx.error # => ctx[:error]
99
+ ctx.errors # => ctx[:errors] || {}
100
+ ```
101
+
102
+ ### `Ctx::Failure` exception
103
+
104
+ `ctx.fail!` raises `Easyop::Ctx::Failure`, a `StandardError` subclass. The exception's `.ctx` attribute holds the failed context, and `.message` is formatted as:
105
+
106
+ ```
107
+ "Operation failed" # when ctx.error is nil
108
+ "Operation failed: <ctx.error>" # when ctx.error is set
109
+ ```
110
+
111
+ ```ruby
112
+ begin
113
+ AuthenticateUser.call!(email: email, password: password)
114
+ rescue Easyop::Ctx::Failure => e
115
+ e.ctx.error # => "Invalid credentials"
116
+ e.message # => "Operation failed: Invalid credentials"
117
+ end
118
+ ```
119
+
120
+ ### Rollback tracking (`called!` / `rollback!`)
121
+
122
+ These methods are used internally by `Easyop::Flow` to track which operations have run and to roll them back on failure. You generally do not call them directly, but they are part of the public `Ctx` API:
123
+
124
+ ```ruby
125
+ ctx.called!(operation_instance) # register an operation as having run
126
+ ctx.rollback! # roll back all registered operations in reverse order
127
+ ```
128
+
129
+ `rollback!` is idempotent — calling it more than once has no additional effect.
130
+
131
+ ### Chainable callbacks (post-call)
132
+
133
+ ```ruby
134
+ AuthenticateUser.call(email: email, password: password)
135
+ .on_success { |ctx| sign_in(ctx.user) }
136
+ .on_failure { |ctx| flash[:alert] = ctx.error }
137
+ ```
138
+
139
+ ### Pattern matching (Ruby 3+)
140
+
141
+ ```ruby
142
+ case AuthenticateUser.call(email: email, password: password)
143
+ in { success: true, user: }
144
+ sign_in(user)
145
+ in { success: false, error: }
146
+ flash[:alert] = error
147
+ end
148
+ ```
149
+
150
+ ### Bang variant
151
+
152
+ ```ruby
153
+ # .call — returns ctx, swallows failures (check ctx.failure?)
154
+ # .call! — returns ctx on success, raises Easyop::Ctx::Failure on failure
155
+
156
+ begin
157
+ ctx = AuthenticateUser.call!(email: email, password: password)
158
+ rescue Easyop::Ctx::Failure => e
159
+ e.ctx.error # => "Invalid credentials"
160
+ end
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Hooks
166
+
167
+ ```ruby
168
+ class NormalizeEmail
169
+ include Easyop::Operation
170
+
171
+ before :strip_whitespace
172
+ after :log_result
173
+ around :with_timing
174
+
175
+ def call
176
+ ctx.normalized = ctx.email.downcase
177
+ end
178
+
179
+ private
180
+
181
+ def strip_whitespace
182
+ ctx.email = ctx.email.to_s.strip
183
+ end
184
+
185
+ def log_result
186
+ Rails.logger.info "Normalized: #{ctx.normalized}" if ctx.success?
187
+ end
188
+
189
+ def with_timing
190
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
+ yield
192
+ Rails.logger.info "Took #{((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)}ms"
193
+ end
194
+ end
195
+ ```
196
+
197
+ Multiple hooks run in declaration order. `after` hooks always run (even on failure). Hooks can be method names (Symbol) or inline blocks:
198
+
199
+ ```ruby
200
+ before { ctx.email = ctx.email.to_s.strip.downcase }
201
+ after { Rails.logger.info ctx.inspect }
202
+ around { |inner| Sentry.with_scope { inner.call } }
203
+ ```
204
+
205
+ ---
206
+
207
+ ## `rescue_from`
208
+
209
+ Handle exceptions without polluting `call` with begin/rescue blocks:
210
+
211
+ ```ruby
212
+ class ParseJson
213
+ include Easyop::Operation
214
+
215
+ rescue_from JSON::ParserError do |e|
216
+ ctx.fail!(error: "Invalid JSON: #{e.message}")
217
+ end
218
+
219
+ def call
220
+ ctx.parsed = JSON.parse(ctx.raw)
221
+ end
222
+ end
223
+ ```
224
+
225
+ Multiple handlers and `with:` method reference syntax:
226
+
227
+ ```ruby
228
+ class ImportData
229
+ include Easyop::Operation
230
+
231
+ rescue_from CSV::MalformedCSVError, with: :handle_bad_csv
232
+ rescue_from ActiveRecord::RecordInvalid do |e|
233
+ ctx.fail!(error: e.message, errors: e.record.errors.to_h)
234
+ end
235
+
236
+ def call
237
+ # ...
238
+ end
239
+
240
+ private
241
+
242
+ def handle_bad_csv(e)
243
+ ctx.fail!(error: "CSV is malformed: #{e.message}")
244
+ end
245
+ end
246
+ ```
247
+
248
+ Handlers are checked in reverse inheritance order — child class handlers take priority over parent class handlers.
249
+
250
+ ---
251
+
252
+ ## Typed Input/Output Schemas
253
+
254
+ Schemas are optional. Declare them to get early validation and inline documentation:
255
+
256
+ ```ruby
257
+ class RegisterUser
258
+ include Easyop::Operation
259
+
260
+ params do
261
+ required :email, String
262
+ required :age, Integer
263
+ optional :plan, String, default: "free"
264
+ optional :admin, :boolean, default: false
265
+ end
266
+
267
+ result do
268
+ required :user, User
269
+ end
270
+
271
+ def call
272
+ ctx.user = User.create!(ctx.slice(:email, :age, :plan))
273
+ end
274
+ end
275
+
276
+ result = RegisterUser.call(email: "alice@example.com", age: 30)
277
+ result.success? # => true
278
+ result.plan # => "free" (default applied)
279
+
280
+ result = RegisterUser.call(email: "bob@example.com")
281
+ result.failure? # => true
282
+ result.error # => "Missing required params field: age"
283
+ ```
284
+
285
+ ### Type shorthands
286
+
287
+ | Symbol | Resolves to |
288
+ |--------|------------|
289
+ | `:boolean` | `TrueClass \| FalseClass` |
290
+ | `:string` | `String` |
291
+ | `:integer` | `Integer` |
292
+ | `:float` | `Float` |
293
+ | `:symbol` | `Symbol` |
294
+ | `:any` | any value |
295
+
296
+ Pass any Ruby class directly: `required :user, User`.
297
+
298
+ ### `inputs` / `outputs` aliases
299
+
300
+ `inputs` is an alias for `params`, and `outputs` is an alias for `result`. They are interchangeable:
301
+
302
+ ```ruby
303
+ class NormalizeAddress
304
+ include Easyop::Operation
305
+
306
+ inputs do
307
+ required :street, String
308
+ required :city, String
309
+ end
310
+
311
+ outputs do
312
+ required :formatted, String
313
+ end
314
+
315
+ def call
316
+ ctx.formatted = "#{ctx.street}, #{ctx.city}"
317
+ end
318
+ end
319
+ ```
320
+
321
+ ### Configuration
322
+
323
+ ```ruby
324
+ Easyop.configure do |c|
325
+ c.strict_types = false # true = ctx.fail! on type mismatch; false = warn (default)
326
+ c.type_adapter = :native # :none, :native (default), :literal, :dry, :active_model
327
+ end
328
+ ```
329
+
330
+ Reset to defaults (useful in tests):
331
+
332
+ ```ruby
333
+ Easyop.reset_config!
334
+ ```
335
+
336
+ ---
337
+
338
+ ## Flow — Composing Operations
339
+
340
+ `Easyop::Flow` runs operations in sequence, sharing one `ctx`. Any failure halts the chain.
341
+
342
+ ```ruby
343
+ class ProcessCheckout
344
+ include Easyop::Flow
345
+
346
+ flow ValidateCart,
347
+ ApplyCoupon,
348
+ ChargePayment,
349
+ CreateOrder,
350
+ SendConfirmation
351
+ end
352
+
353
+ result = ProcessCheckout.call(user: current_user, cart: current_cart)
354
+ result.success? # => true
355
+ result.order # => #<Order ...>
356
+ ```
357
+
358
+ ### Rollback
359
+
360
+ Each step can define `rollback`. On failure, rollback runs on all completed steps in reverse:
361
+
362
+ ```ruby
363
+ class ChargePayment
364
+ include Easyop::Operation
365
+
366
+ def call
367
+ ctx.charge = Stripe::Charge.create(amount: ctx.total, source: ctx.token)
368
+ end
369
+
370
+ def rollback
371
+ Stripe::Refund.create(charge: ctx.charge.id) if ctx.charge
372
+ end
373
+ end
374
+ ```
375
+
376
+ ### `skip_if` — Optional steps
377
+
378
+ Declare when a step should be bypassed:
379
+
380
+ ```ruby
381
+ class ApplyCoupon
382
+ include Easyop::Operation
383
+
384
+ skip_if { |ctx| !ctx.coupon_code? || ctx.coupon_code.to_s.empty? }
385
+
386
+ def call
387
+ ctx.discount = CouponService.apply(ctx.coupon_code)
388
+ end
389
+ end
390
+
391
+ class ProcessCheckout
392
+ include Easyop::Flow
393
+
394
+ flow ValidateCart,
395
+ ApplyCoupon, # automatically skipped when no coupon_code
396
+ ChargePayment,
397
+ CreateOrder
398
+ end
399
+ ```
400
+
401
+ Skipped steps are never added to the rollback list.
402
+
403
+ > **Note:** `skip_if` is evaluated by the Flow runner. Calling an operation directly (e.g. `MyOp.call(...)`) bypasses the skip check entirely — `skip_if` is a Flow concept, not an operation-level guard.
404
+
405
+ ### Lambda guards (inline)
406
+
407
+ Place a lambda immediately before a step to gate it:
408
+
409
+ ```ruby
410
+ flow ValidateCart,
411
+ ->(ctx) { ctx.coupon_code? }, ApplyCoupon,
412
+ ChargePayment
413
+ ```
414
+
415
+ ### Nested flows
416
+
417
+ A Flow can be a step inside another Flow:
418
+
419
+ ```ruby
420
+ class ProcessOrder
421
+ include Easyop::Flow
422
+ flow ValidateCart, ChargePayment
423
+ end
424
+
425
+ class FullCheckout
426
+ include Easyop::Flow
427
+ flow ProcessOrder, SendConfirmation, NotifyAdmin
428
+ end
429
+ ```
430
+
431
+ ---
432
+
433
+ ## `prepare` — Pre-registered Callbacks
434
+
435
+ `FlowClass.prepare` returns a `FlowBuilder` that accumulates callbacks before executing the flow. The `flow` class method is reserved for declaring steps — `prepare` is the clear, unambiguous entry point for callback registration.
436
+
437
+ ### Block callbacks
438
+
439
+ ```ruby
440
+ ProcessCheckout.prepare
441
+ .on_success { |ctx| redirect_to order_path(ctx.order) }
442
+ .on_failure { |ctx| flash[:error] = ctx.error; redirect_back }
443
+ .call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
444
+ ```
445
+
446
+ ### Symbol callbacks with `bind_with`
447
+
448
+ Bind a host object (e.g. a Rails controller) to dispatch to named methods:
449
+
450
+ ```ruby
451
+ # In a Rails controller:
452
+ def create
453
+ ProcessCheckout.prepare
454
+ .bind_with(self)
455
+ .on(success: :order_created, fail: :checkout_failed)
456
+ .call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
457
+ end
458
+
459
+ private
460
+
461
+ def order_created(ctx)
462
+ redirect_to order_path(ctx.order)
463
+ end
464
+
465
+ def checkout_failed(ctx)
466
+ flash[:error] = ctx.error
467
+ render :new
468
+ end
469
+ ```
470
+
471
+ Zero-arity methods are supported (ctx is not passed):
472
+
473
+ ```ruby
474
+ def order_created
475
+ redirect_to orders_path
476
+ end
477
+ ```
478
+
479
+ ### Multiple callbacks
480
+
481
+ ```ruby
482
+ ProcessCheckout.prepare
483
+ .on_success { |ctx| Analytics.track("checkout", order_id: ctx.order.id) }
484
+ .on_success { |ctx| redirect_to order_path(ctx.order) }
485
+ .on_failure { |ctx| Rails.logger.error("Checkout failed: #{ctx.error}") }
486
+ .on_failure { |ctx| render json: { error: ctx.error }, status: 422 }
487
+ .call(attrs)
488
+ ```
489
+
490
+ ---
491
+
492
+ ## Inheritance — Shared Base Class
493
+
494
+ ```ruby
495
+ class ApplicationOperation
496
+ include Easyop::Operation
497
+
498
+ rescue_from StandardError do |e|
499
+ Sentry.capture_exception(e)
500
+ ctx.fail!(error: "An unexpected error occurred")
501
+ end
502
+ end
503
+
504
+ class MyOp < ApplicationOperation
505
+ def call
506
+ # StandardError is caught and handled by ApplicationOperation
507
+ end
508
+ end
509
+ ```
510
+
511
+ ---
512
+
513
+ ## Plugins
514
+
515
+ EasyOp has an opt-in plugin system. Plugins are installed on an operation class (or a shared base class) with the `plugin` DSL. Every subclass inherits the plugins of its parent.
516
+
517
+ ```ruby
518
+ class ApplicationOperation
519
+ include Easyop::Operation
520
+
521
+ plugin Easyop::Plugins::Instrumentation
522
+ plugin Easyop::Plugins::Recording, model: OperationLog
523
+ plugin Easyop::Plugins::Async, queue: "operations"
524
+ end
525
+ ```
526
+
527
+ You can inspect which plugins have been installed on an operation class:
528
+
529
+ ```ruby
530
+ ApplicationOperation._registered_plugins
531
+ # => [
532
+ # { plugin: Easyop::Plugins::Instrumentation, options: {} },
533
+ # { plugin: Easyop::Plugins::Recording, options: { model: OperationLog } },
534
+ # { plugin: Easyop::Plugins::Async, options: { queue: "operations" } }
535
+ # ]
536
+ ```
537
+
538
+ Plugins are **not** required automatically — require the ones you use:
539
+
540
+ ```ruby
541
+ require "easyop/plugins/instrumentation"
542
+ require "easyop/plugins/recording"
543
+ require "easyop/plugins/async"
544
+ ```
545
+
546
+ ---
547
+
548
+ ### Plugin: Instrumentation
549
+
550
+ Emits an `ActiveSupport::Notifications` event after every operation call. Requires ActiveSupport (included with Rails).
551
+
552
+ ```ruby
553
+ require "easyop/plugins/instrumentation"
554
+
555
+ class ApplicationOperation
556
+ include Easyop::Operation
557
+ plugin Easyop::Plugins::Instrumentation
558
+ end
559
+ ```
560
+
561
+ **Event:** `"easyop.operation.call"`
562
+
563
+ **Payload:**
564
+
565
+ | Key | Type | Description |
566
+ |---|---|---|
567
+ | `:operation` | String | Class name, e.g. `"Users::Register"` |
568
+ | `:success` | Boolean | `true` unless `ctx.fail!` was called |
569
+ | `:error` | String \| nil | `ctx.error` on failure, `nil` on success |
570
+ | `:duration` | Float | Elapsed milliseconds |
571
+ | `:ctx` | `Easyop::Ctx` | The result object |
572
+
573
+ **Subscribe manually:**
574
+
575
+ ```ruby
576
+ ActiveSupport::Notifications.subscribe("easyop.operation.call") do |event|
577
+ p = event.payload
578
+ Rails.logger.info "[#{p[:operation]}] #{p[:success] ? 'ok' : 'FAILED'} (#{event.duration.round(1)}ms)"
579
+ end
580
+ ```
581
+
582
+ **Built-in log subscriber** — add this to an initializer for zero-config logging:
583
+
584
+ ```ruby
585
+ # config/initializers/easyop.rb
586
+ Easyop::Plugins::Instrumentation.attach_log_subscriber
587
+ ```
588
+
589
+ Output format:
590
+ ```
591
+ [EasyOp] Users::Register ok (4.2ms)
592
+ [EasyOp] Users::Authenticate FAILED (1.1ms) — Invalid email or password
593
+ ```
594
+
595
+ ---
596
+
597
+ ### Plugin: Recording
598
+
599
+ Persists every operation execution to an ActiveRecord model. Useful for audit trails, debugging, and performance monitoring.
600
+
601
+ ```ruby
602
+ require "easyop/plugins/recording"
603
+
604
+ class ApplicationOperation
605
+ include Easyop::Operation
606
+ plugin Easyop::Plugins::Recording, model: OperationLog
607
+ end
608
+ ```
609
+
610
+ **Options:**
611
+
612
+ | Option | Default | Description |
613
+ |---|---|---|
614
+ | `model:` | required | ActiveRecord class to write logs into |
615
+ | `record_params:` | `true` | Set `false` to skip serializing ctx params |
616
+
617
+ **Required model columns:**
618
+
619
+ ```ruby
620
+ create_table :operation_logs do |t|
621
+ t.string :operation_name, null: false
622
+ t.boolean :success, null: false
623
+ t.string :error_message
624
+ t.text :params_data # JSON — ctx attrs (sensitive keys scrubbed)
625
+ t.float :duration_ms
626
+ t.datetime :performed_at, null: false
627
+ end
628
+ ```
629
+
630
+ 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
+
632
+ **Opt out per class:**
633
+
634
+ ```ruby
635
+ class Newsletter::SendBroadcast < ApplicationOperation
636
+ recording false # skip logging for this operation
637
+ end
638
+ ```
639
+
640
+ Recording failures are swallowed and logged as warnings — a failed log write never breaks the operation.
641
+
642
+ ---
643
+
644
+ ### Plugin: Async
645
+
646
+ Adds `.call_async` to any operation class, enqueuing execution as an ActiveJob. Requires ActiveJob (included with Rails).
647
+
648
+ ```ruby
649
+ require "easyop/plugins/async"
650
+
651
+ class Newsletter::SendBroadcast < ApplicationOperation
652
+ plugin Easyop::Plugins::Async, queue: "broadcasts"
653
+ end
654
+ ```
655
+
656
+ **Enqueue immediately:**
657
+
658
+ ```ruby
659
+ Newsletter::SendBroadcast.call_async(subject: "Hello", body: "World")
660
+ ```
661
+
662
+ **With scheduling:**
663
+
664
+ ```ruby
665
+ # Run after a delay
666
+ Newsletter::SendBroadcast.call_async(attrs, wait: 10.minutes)
667
+
668
+ # Run at a specific time
669
+ Newsletter::SendBroadcast.call_async(attrs, wait_until: Date.tomorrow.noon)
670
+
671
+ # Override the queue at call time
672
+ Newsletter::SendBroadcast.call_async(attrs, queue: "low_priority")
673
+ ```
674
+
675
+ **ActiveRecord objects** are serialized by `(class, id)` and re-fetched in the job:
676
+
677
+ ```ruby
678
+ # This works — Article is serialized as { "__ar_class" => "Article", "__ar_id" => 42 }
679
+ Newsletter::SendBroadcast.call_async(article: @article, subject: "Hello")
680
+ ```
681
+
682
+ Only pass serializable values: `String`, `Integer`, `Float`, `Boolean`, `nil`, `Hash`, `Array`, or `ActiveRecord::Base`.
683
+
684
+ The plugin defines `Easyop::Plugins::Async::Job` lazily (on first call to `.call_async`) so you can require the plugin before ActiveJob loads.
685
+
686
+ ---
687
+
688
+ ### Plugin: Transactional
689
+
690
+ Wraps every operation call in a database transaction. On `ctx.fail!` or any unhandled exception the transaction is rolled back. Supports **ActiveRecord** and **Sequel**.
691
+
692
+ ```ruby
693
+ require "easyop/plugins/transactional"
694
+
695
+ # Per operation:
696
+ class TransferFunds < ApplicationOperation
697
+ plugin Easyop::Plugins::Transactional
698
+
699
+ def call
700
+ ctx.from_account.debit!(ctx.amount)
701
+ ctx.to_account.credit!(ctx.amount)
702
+ ctx.transaction_id = SecureRandom.uuid
703
+ end
704
+ end
705
+
706
+ # Or globally on ApplicationOperation — all subclasses get transactions:
707
+ class ApplicationOperation
708
+ include Easyop::Operation
709
+ plugin Easyop::Plugins::Transactional
710
+ end
711
+ ```
712
+
713
+ Also works with the classic `include` style:
714
+
715
+ ```ruby
716
+ class TransferFunds
717
+ include Easyop::Operation
718
+ include Easyop::Plugins::Transactional
719
+ end
720
+ ```
721
+
722
+ **Opt out** per class when the parent has transactions enabled:
723
+
724
+ ```ruby
725
+ class ReadOnlyReport < ApplicationOperation
726
+ transactional false # no transaction overhead for read-only ops
727
+ end
728
+ ```
729
+
730
+ **Options:** none — the adapter is detected automatically (ActiveRecord first, then Sequel).
731
+
732
+ **Placement in the lifecycle:** The transaction wraps the entire `prepare` chain — before hooks, `call`, and after hooks all run inside the same transaction. If `ctx.fail!` is called (raising `Ctx::Failure`), the transaction rolls back.
733
+
734
+ **With Flow:** When using `Easyop::Plugins::Transactional` inside a Flow step, the transaction is scoped to that one step, not the whole flow. For a flow-wide transaction, include it on the flow class itself.
735
+
736
+ ---
737
+
738
+ ### Building Your Own Plugin
739
+
740
+ A plugin is any object responding to `.install(base_class, **options)`. Inherit from `Easyop::Plugins::Base` for a clear interface:
741
+
742
+ ```ruby
743
+ require "easyop/plugins/base"
744
+
745
+ module MyPlugin < Easyop::Plugins::Base
746
+ def self.install(base, **options)
747
+ # 1. Prepend a module to wrap _easyop_run (wraps the entire lifecycle)
748
+ base.prepend(RunWrapper)
749
+
750
+ # 2. Extend to add class-level DSL
751
+ base.extend(ClassMethods)
752
+
753
+ # 3. Store configuration on the class
754
+ base.instance_variable_set(:@_my_plugin_option, options[:my_option])
755
+ end
756
+
757
+ module ClassMethods
758
+ # DSL method for subclasses to configure the plugin
759
+ def my_plugin_option(value)
760
+ @_my_plugin_option = value
761
+ end
762
+
763
+ def _my_plugin_option
764
+ @_my_plugin_option ||
765
+ (superclass.respond_to?(:_my_plugin_option) ? superclass._my_plugin_option : nil)
766
+ end
767
+ end
768
+
769
+ module RunWrapper
770
+ # Override _easyop_run to wrap the full operation lifecycle.
771
+ # Always call super and return ctx.
772
+ def _easyop_run(ctx, raise_on_failure:)
773
+ # before
774
+ puts "Starting #{self.class.name}"
775
+
776
+ super.tap do
777
+ # after (ctx is fully settled — success? / failure? are final here)
778
+ puts "Finished #{self.class.name}: #{ctx.success? ? 'ok' : 'FAILED'}"
779
+ end
780
+ end
781
+ end
782
+ end
783
+ ```
784
+
785
+ **Activate it:**
786
+
787
+ ```ruby
788
+ class ApplicationOperation
789
+ include Easyop::Operation
790
+ plugin MyPlugin, my_option: "value"
791
+ end
792
+ ```
793
+
794
+ **Per-class opt-out pattern** (same pattern used by the Recording plugin):
795
+
796
+ ```ruby
797
+ module MyPlugin < Easyop::Plugins::Base
798
+ module ClassMethods
799
+ def my_plugin(enabled)
800
+ @_my_plugin_enabled = enabled
801
+ end
802
+
803
+ def _my_plugin_enabled?
804
+ return @_my_plugin_enabled if instance_variable_defined?(:@_my_plugin_enabled)
805
+ superclass.respond_to?(:_my_plugin_enabled?) ? superclass._my_plugin_enabled? : true
806
+ end
807
+ end
808
+
809
+ module RunWrapper
810
+ def _easyop_run(ctx, raise_on_failure:)
811
+ return super unless self.class._my_plugin_enabled?
812
+ # ... plugin logic
813
+ super
814
+ end
815
+ end
816
+ end
817
+
818
+ # Then in an operation:
819
+ class InternalOp < ApplicationOperation
820
+ my_plugin false
821
+ end
822
+ ```
823
+
824
+ **Plugin execution order** is determined by the order `plugin` calls appear. Each plugin prepends its `RunWrapper`, so the last plugin installed is the outermost wrapper:
825
+
826
+ ```
827
+ Plugin3::RunWrapper (outermost)
828
+ Plugin2::RunWrapper
829
+ Plugin1::RunWrapper
830
+ prepare { before → call → after } (innermost)
831
+ ```
832
+
833
+ **Naming convention:** prefix all internal instance methods with `_pluginname_` (e.g. `_recording_persist!`, `_async_serialize`) to avoid collisions with application code.
834
+
835
+ ---
836
+
837
+ ## Rails Controller Integration
838
+
839
+ ### Pattern 1 — Inline callbacks
840
+
841
+ ```ruby
842
+ class UsersController < ApplicationController
843
+ def create
844
+ CreateUser.call(user_params)
845
+ .on_success { |ctx| redirect_to profile_path(ctx.user) }
846
+ .on_failure { |ctx| render :new, locals: { errors: ctx.errors } }
847
+ end
848
+ end
849
+ ```
850
+
851
+ ### Pattern 2 — `prepare` and `bind_with`
852
+
853
+ ```ruby
854
+ class CheckoutsController < ApplicationController
855
+ def create
856
+ ProcessCheckout.prepare
857
+ .bind_with(self)
858
+ .on(success: :checkout_complete, fail: :checkout_failed)
859
+ .call(user: current_user, cart: current_cart, coupon_code: params[:coupon])
860
+ end
861
+
862
+ private
863
+
864
+ def checkout_complete(ctx)
865
+ redirect_to order_path(ctx.order), notice: "Order placed!"
866
+ end
867
+
868
+ def checkout_failed(ctx)
869
+ flash[:error] = ctx.error
870
+ render :new
871
+ end
872
+ end
873
+ ```
874
+
875
+ ### Pattern 3 — Pattern matching
876
+
877
+ ```ruby
878
+ def create
879
+ case CreateUser.call(user_params)
880
+ in { success: true, user: }
881
+ redirect_to profile_path(user)
882
+ in { success: false, errors: Hash => errs }
883
+ render :new, locals: { errors: errs }
884
+ in { success: false, error: String => msg }
885
+ flash[:alert] = msg
886
+ render :new
887
+ end
888
+ end
889
+ ```
890
+
891
+ ---
892
+
893
+ ## Full Checkout Example
894
+
895
+ ```ruby
896
+ class ValidateCart
897
+ include Easyop::Operation
898
+
899
+ def call
900
+ ctx.fail!(error: "Cart is empty") if ctx.cart.items.empty?
901
+ ctx.total = ctx.cart.items.sum(&:price)
902
+ end
903
+ end
904
+
905
+ class ApplyCoupon
906
+ include Easyop::Operation
907
+
908
+ skip_if { |ctx| !ctx.coupon_code? || ctx.coupon_code.to_s.empty? }
909
+
910
+ def call
911
+ coupon = Coupon.find_by(code: ctx.coupon_code)
912
+ ctx.fail!(error: "Invalid coupon") unless coupon&.active?
913
+ ctx.total -= coupon.discount_amount
914
+ ctx.discount = coupon.discount_amount
915
+ end
916
+ end
917
+
918
+ class ChargePayment
919
+ include Easyop::Operation
920
+
921
+ def call
922
+ charge = Stripe::Charge.create(amount: ctx.total, source: ctx.payment_token)
923
+ ctx.charge = charge
924
+ end
925
+
926
+ def rollback
927
+ Stripe::Refund.create(charge: ctx.charge.id) if ctx.charge
928
+ end
929
+ end
930
+
931
+ class CreateOrder
932
+ include Easyop::Operation
933
+
934
+ def call
935
+ ctx.order = Order.create!(
936
+ user: ctx.user,
937
+ total: ctx.total,
938
+ charge: ctx.charge.id,
939
+ discount: ctx.discount
940
+ )
941
+ end
942
+
943
+ def rollback
944
+ ctx.order.destroy! if ctx.order
945
+ end
946
+ end
947
+
948
+ class SendConfirmation
949
+ include Easyop::Operation
950
+
951
+ def call
952
+ OrderMailer.confirmation(ctx.order).deliver_later
953
+ end
954
+ end
955
+
956
+ class ProcessCheckout
957
+ include Easyop::Flow
958
+
959
+ flow ValidateCart,
960
+ ApplyCoupon,
961
+ ChargePayment,
962
+ CreateOrder,
963
+ SendConfirmation
964
+ end
965
+
966
+ # Controller:
967
+ ProcessCheckout.prepare
968
+ .bind_with(self)
969
+ .on(success: :order_created, fail: :checkout_failed)
970
+ .call(
971
+ user: current_user,
972
+ cart: current_cart,
973
+ payment_token: params[:stripe_token],
974
+ coupon_code: params[:coupon_code]
975
+ )
976
+ ```
977
+
978
+ ---
979
+
980
+ ## Running Examples
981
+
982
+ ```
983
+ ruby examples/usage.rb
984
+ ```
985
+
986
+ ## Example Rails App
987
+
988
+ A full Rails 8 blog application demonstrating every EasyOp feature in real-world code lives in `/examples/easyop_test_app/`. It is **not included in the gem** — only in the repository.
989
+
990
+ ```
991
+ /examples/easyop_test_app/
992
+ ```
993
+
994
+ The app covers:
995
+
996
+ | Feature | Where to look |
997
+ |---|---|
998
+ | Basic operations | `app/operations/users/`, `app/operations/articles/` |
999
+ | Typed `params` schema | `app/operations/users/register.rb` |
1000
+ | `rescue_from` | `app/operations/application_operation.rb` |
1001
+ | Flow with rollback | `app/operations/flows/transfer_credits.rb` |
1002
+ | `skip_if` / lambda guards | `Flows::TransferCredits::ApplyFee` |
1003
+ | Instrumentation plugin | `ApplicationOperation` → `plugin Easyop::Plugins::Instrumentation` |
1004
+ | Recording plugin | `ApplicationOperation` → persists to `operation_logs` table |
1005
+ | Async plugin | `app/operations/newsletter/subscribe.rb` |
1006
+ | Transactional plugin | `ApplicationOperation` → all DB ops wrapped in transactions |
1007
+ | Rails controller integration | `app/controllers/articles_controller.rb`, `transfers_controller.rb` |
1008
+
1009
+ **Running the example app:**
1010
+
1011
+ ```bash
1012
+ cd /examples/easyop_test_app
1013
+ bundle install
1014
+ bin/rails db:create db:migrate db:seed
1015
+ bin/rails server -p 3002
1016
+ ```
1017
+
1018
+ Seed accounts: `alice@example.com` / `password123` (500 credits), `bob`, `carol`, `dave` (0 credits — tests insufficient-funds error).
1019
+
1020
+ ## Running Specs
1021
+
1022
+ ```
1023
+ bundle exec rspec
1024
+ ```
1025
+
1026
+ ---
1027
+
1028
+ ## AI Tools — Claude Skill & LLM Context
1029
+
1030
+ EasyOp ships with two sets of AI helpers so any LLM can write idiomatic operations without you re-explaining the API.
1031
+
1032
+ ### Claude Code Skill
1033
+
1034
+ Copy the plugin into your project and Claude will auto-activate whenever you mention operations, flows, or `easyop`:
1035
+
1036
+ ```bash
1037
+ # From your project root
1038
+ cp -r path/to/easyop/claude-plugin/.claude-plugin .
1039
+ cp -r path/to/easyop/claude-plugin/skills .
1040
+ ```
1041
+
1042
+ Or reference it from your existing `CLAUDE.md`:
1043
+
1044
+ ```markdown
1045
+ ## EasyOp
1046
+ @path/to/easyop/claude-plugin/skills/easyop/SKILL.md
1047
+ ```
1048
+
1049
+ Once installed, Claude generates correct boilerplate for operations, flows, rollback, plugins, and RSpec tests — no copy-pasting the README.
1050
+
1051
+ ### LLM Context Files (`llms/`)
1052
+
1053
+ | File | When to use |
1054
+ |---|---|
1055
+ | `llms/overview.md` | Before asking an AI to **modify or extend the gem** — covers the full file map and plugin architecture |
1056
+ | `llms/usage.md` | Before asking an AI to **write application code** — covers all patterns: basic ops, flows, plugins, Rails integration, testing |
1057
+
1058
+ Paste either file as a system message in Claude.ai / ChatGPT / Gemini, or use Cursor's "Add to context" feature before asking your question.
1059
+
1060
+ See the **[AI Tools docs page](https://pniemczyk.github.io/easyop/ai-tools.html)** for full details including programmatic usage with the Anthropic API.
1061
+
1062
+ ---
1063
+
1064
+ ## Module Reference
1065
+
1066
+ ### Core
1067
+
1068
+ | Class/Module | Description |
1069
+ |---|---|
1070
+ | `Easyop::Operation` | Core mixin — include in any class to make it an operation |
1071
+ | `Easyop::Flow` | Includes `Operation`; adds `flow` DSL and sequential execution |
1072
+ | `Easyop::FlowBuilder` | Builder returned by `FlowClass.prepare` |
1073
+ | `Easyop::Ctx` | The shared context/result object |
1074
+ | `Easyop::Ctx::Failure` | Raised by `ctx.fail!`; rescued by `.call`, propagated by `.call!` |
1075
+ | `Easyop::Hooks` | `before`/`after`/`around` hook system (no ActiveSupport) |
1076
+ | `Easyop::Rescuable` | `rescue_from` DSL |
1077
+ | `Easyop::Skip` | `skip_if` DSL for conditional step execution in flows |
1078
+ | `Easyop::Schema` | `params`/`result` typed schema DSL |
1079
+
1080
+ ### Plugins (opt-in)
1081
+
1082
+ | Class/Module | Require | Description |
1083
+ |---|---|---|
1084
+ | `Easyop::Plugins::Base` | `easyop/plugins/base` | Abstract base — inherit to build custom plugins |
1085
+ | `Easyop::Plugins::Instrumentation` | `easyop/plugins/instrumentation` | Emits `"easyop.operation.call"` via `ActiveSupport::Notifications` |
1086
+ | `Easyop::Plugins::Recording` | `easyop/plugins/recording` | Persists every execution to an ActiveRecord model |
1087
+ | `Easyop::Plugins::Async` | `easyop/plugins/async` | Adds `.call_async` via ActiveJob with AR object serialization |
1088
+ | `Easyop::Plugins::Async::Job` | (created lazily) | The ActiveJob class that deserializes and runs the operation |
1089
+ | `Easyop::Plugins::Transactional` | `easyop/plugins/transactional` | Wraps operation in an AR/Sequel transaction; `transactional false` to opt out |