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.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Plugins
5
+ # Enables async execution via ActiveJob.
6
+ #
7
+ # Usage:
8
+ # class Newsletter::SendBroadcast < ApplicationOperation
9
+ # plugin Easyop::Plugins::Async, queue: "broadcasts"
10
+ # end
11
+ #
12
+ # # Enqueue immediately:
13
+ # Newsletter::SendBroadcast.call_async(subject: "Hello", body: "World")
14
+ #
15
+ # # With scheduling options:
16
+ # Newsletter::SendBroadcast.call_async(attrs, wait: 10.minutes)
17
+ # Newsletter::SendBroadcast.call_async(attrs, wait_until: Date.tomorrow.noon)
18
+ # Newsletter::SendBroadcast.call_async(attrs, queue: "low_priority")
19
+ #
20
+ # ActiveRecord objects are serialized by (class, id) and re-fetched in the job.
21
+ # Only serializable values (String, Integer, Float, Boolean, nil, Hash, Array,
22
+ # or ActiveRecord::Base) should be passed.
23
+ module Async
24
+ def self.install(base, queue: "default", **_options)
25
+ base.extend(ClassMethods)
26
+ base.instance_variable_set(:@_async_default_queue, queue)
27
+ end
28
+
29
+ module ClassMethods
30
+ # Enqueue the operation as a background job.
31
+ #
32
+ # MyOp.call_async(email: "x@y.com")
33
+ # MyOp.call_async(email: "x@y.com", wait: 5.minutes, queue: "low")
34
+ def call_async(attrs = {}, wait: nil, wait_until: nil, queue: nil, **extra_attrs)
35
+ merged_attrs = attrs.merge(extra_attrs)
36
+ _async_ensure_active_job!
37
+ job = Easyop::Plugins::Async.job_class
38
+ job = job.set(queue: queue || _async_default_queue)
39
+ job = job.set(wait: wait) if wait
40
+ job = job.set(wait_until: wait_until) if wait_until
41
+ job.perform_later(name, _async_serialize(merged_attrs))
42
+ end
43
+
44
+ def _async_default_queue
45
+ @_async_default_queue ||
46
+ (superclass.respond_to?(:_async_default_queue) ? superclass._async_default_queue : "default")
47
+ end
48
+
49
+ private
50
+
51
+ def _async_ensure_active_job!
52
+ return if defined?(ActiveJob::Base)
53
+ raise LoadError, "ActiveJob is required for async operations."
54
+ end
55
+
56
+ # Serialize attrs for GlobalID / JSON storage.
57
+ # AR objects → { "__ar_class" => "User", "__ar_id" => 42 }
58
+ def _async_serialize(attrs)
59
+ attrs.each_with_object({}) do |(k, v), h|
60
+ h[k.to_s] = if defined?(ActiveRecord::Base) && v.is_a?(ActiveRecord::Base)
61
+ { "__ar_class" => v.class.name, "__ar_id" => v.id }
62
+ else
63
+ v
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ # The ActiveJob that deserializes and runs the operation.
70
+ # Defined lazily so this file can be required before ActiveJob loads.
71
+ def self.job_class
72
+ @job_class ||= begin
73
+ raise LoadError, "ActiveJob is required for Easyop::Plugins::Async" unless defined?(ActiveJob::Base)
74
+
75
+ klass = Class.new(ActiveJob::Base) do
76
+ queue_as :default
77
+
78
+ def perform(operation_class, attrs)
79
+ op_klass = operation_class.constantize
80
+ deserialized = attrs.each_with_object({}) do |(k, v), h|
81
+ h[k.to_sym] = if v.is_a?(Hash) && v["__ar_class"]
82
+ v["__ar_class"].constantize.find(v["__ar_id"])
83
+ else
84
+ v
85
+ end
86
+ end
87
+ op_klass.call(deserialized)
88
+ end
89
+ end
90
+
91
+ # Give the anonymous class a constant name for serialization
92
+ Easyop::Plugins::Async.const_set(:Job, klass) unless const_defined?(:Job)
93
+ klass
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,27 @@
1
+ module Easyop
2
+ module Plugins
3
+ # Abstract base for EasyOp plugins.
4
+ #
5
+ # A plugin is any object responding to `.install(base_class, **options)`.
6
+ # Inherit from Base for convenience and documentation:
7
+ #
8
+ # module MyPlugin < Easyop::Plugins::Base
9
+ # def self.install(base, **options)
10
+ # base.prepend(RunWrapper)
11
+ # base.extend(ClassMethods)
12
+ # end
13
+ # end
14
+ #
15
+ # Plugins are activated via the `plugin` DSL on operation classes:
16
+ #
17
+ # class ApplicationOperation
18
+ # include Easyop::Operation
19
+ # plugin MyPlugin, option: :value
20
+ # end
21
+ class Base
22
+ def self.install(_base, **_options)
23
+ raise NotImplementedError, "#{name}.install(base, **options) must be implemented"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Plugins
5
+ # Instruments every operation call via ActiveSupport::Notifications.
6
+ #
7
+ # Install on ApplicationOperation (propagates to all subclasses):
8
+ #
9
+ # class ApplicationOperation
10
+ # include Easyop::Operation
11
+ # plugin Easyop::Plugins::Instrumentation
12
+ # end
13
+ #
14
+ # Event: "easyop.operation.call"
15
+ # Payload keys:
16
+ # :operation — String class name, e.g. "Users::Register"
17
+ # :success — Boolean
18
+ # :error — String | nil (ctx.error on failure)
19
+ # :duration — Float ms
20
+ # :ctx — The Easyop::Ctx object (read-only reference)
21
+ #
22
+ # Subscribe manually:
23
+ # ActiveSupport::Notifications.subscribe("easyop.operation.call") do |event|
24
+ # Rails.logger.info "[EasyOp] #{event.payload[:operation]} — #{event.payload[:success] ? 'ok' : 'FAILED'}"
25
+ # end
26
+ #
27
+ # Or use the built-in log subscriber:
28
+ # Easyop::Plugins::Instrumentation.attach_log_subscriber
29
+ module Instrumentation
30
+ EVENT = "easyop.operation.call"
31
+
32
+ def self.install(base, **_options)
33
+ base.prepend(RunWrapper)
34
+ end
35
+
36
+ # Attach a default subscriber that logs to Rails.logger (or stdout).
37
+ # Call once in an initializer: Easyop::Plugins::Instrumentation.attach_log_subscriber
38
+ def self.attach_log_subscriber
39
+ ActiveSupport::Notifications.subscribe(EVENT) do |*args|
40
+ event = ActiveSupport::Notifications::Event.new(*args)
41
+ p = event.payload
42
+ next if p[:operation].nil?
43
+
44
+ status = p[:success] ? "ok" : "FAILED"
45
+ ms = event.duration.round(1)
46
+ line = "[EasyOp] #{p[:operation]} #{status} (#{ms}ms)"
47
+ line += " — #{p[:error]}" if p[:error]
48
+
49
+ logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : Logger.new($stdout)
50
+ if p[:success]
51
+ logger.info line
52
+ else
53
+ logger.warn line
54
+ end
55
+ end
56
+ end
57
+
58
+ module RunWrapper
59
+ def _easyop_run(ctx, raise_on_failure:)
60
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ payload = { operation: self.class.name, success: nil, error: nil, duration: nil, ctx: ctx }
62
+
63
+ ActiveSupport::Notifications.instrument(EVENT, payload) do
64
+ super.tap do
65
+ payload[:success] = ctx.success?
66
+ payload[:error] = ctx.error if ctx.failure?
67
+ payload[:duration] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Easyop
4
+ module Plugins
5
+ # Records operation executions to a database model.
6
+ #
7
+ # Usage:
8
+ # class ApplicationOperation
9
+ # include Easyop::Operation
10
+ # plugin Easyop::Plugins::Recording, model: OperationLog
11
+ # end
12
+ #
13
+ # Required model columns (create with the generator migration):
14
+ # operation_name :string, null: false
15
+ # success :boolean, null: false
16
+ # error_message :string
17
+ # params_data :text # stored as JSON
18
+ # duration_ms :float
19
+ # performed_at :datetime, null: false
20
+ #
21
+ # Opt out per operation class:
22
+ # class MyOp < ApplicationOperation
23
+ # recording false
24
+ # end
25
+ #
26
+ # Options:
27
+ # model: (required) ActiveRecord class
28
+ # record_params: true pass false to skip params serialization
29
+ module Recording
30
+ # Sensitive keys scrubbed from params_data before persisting.
31
+ SCRUBBED_KEYS = %i[password password_confirmation token secret api_key].freeze
32
+
33
+ def self.install(base, model:, record_params: true, **_options)
34
+ base.extend(ClassMethods)
35
+ base.prepend(RunWrapper)
36
+ base.instance_variable_set(:@_recording_model, model)
37
+ base.instance_variable_set(:@_recording_record_params, record_params)
38
+ end
39
+
40
+ module ClassMethods
41
+ # Disable recording for this class: `recording false`
42
+ def recording(enabled)
43
+ @_recording_enabled = enabled
44
+ end
45
+
46
+ def _recording_enabled?
47
+ return @_recording_enabled if instance_variable_defined?(:@_recording_enabled)
48
+ superclass.respond_to?(:_recording_enabled?) ? superclass._recording_enabled? : true
49
+ end
50
+
51
+ def _recording_model
52
+ @_recording_model ||
53
+ (superclass.respond_to?(:_recording_model) ? superclass._recording_model : nil)
54
+ end
55
+
56
+ def _recording_record_params?
57
+ if instance_variable_defined?(:@_recording_record_params)
58
+ @_recording_record_params
59
+ elsif superclass.respond_to?(:_recording_record_params?)
60
+ superclass._recording_record_params?
61
+ else
62
+ true
63
+ end
64
+ end
65
+ end
66
+
67
+ module RunWrapper
68
+ def _easyop_run(ctx, raise_on_failure:)
69
+ return super unless self.class._recording_enabled?
70
+ return super unless (model = self.class._recording_model)
71
+ return super unless self.class.name # skip anonymous classes
72
+
73
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ super.tap do
75
+ ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
76
+ _recording_persist!(ctx, model, ms)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def _recording_persist!(ctx, model, duration_ms)
83
+ attrs = {
84
+ operation_name: self.class.name,
85
+ success: ctx.success?,
86
+ error_message: ctx.error,
87
+ performed_at: Time.current,
88
+ duration_ms: duration_ms
89
+ }
90
+ attrs[:params_data] = _recording_safe_params(ctx) if self.class._recording_record_params?
91
+
92
+ # Only write columns the model actually has
93
+ safe = attrs.select { |k, _| model.column_names.include?(k.to_s) }
94
+ model.create!(safe)
95
+ rescue => e
96
+ _recording_warn(e)
97
+ end
98
+
99
+ def _recording_safe_params(ctx)
100
+ ctx.to_h
101
+ .except(*SCRUBBED_KEYS)
102
+ .transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
103
+ .to_json
104
+ rescue
105
+ nil
106
+ end
107
+
108
+ def _recording_warn(err)
109
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
110
+ Rails.logger.warn "[EasyOp::Recording] Failed to record #{self.class.name}: #{err.message}"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,69 @@
1
+ module Easyop
2
+ module Plugins
3
+ # Wraps the entire operation (including before/after hooks) in a database
4
+ # transaction. On ctx.fail! or any unhandled exception the transaction rolls back.
5
+ #
6
+ # Supports ActiveRecord and Sequel out of the box.
7
+ #
8
+ # Usage — include style (classic):
9
+ # class TransferFunds
10
+ # include Easyop::Operation
11
+ # include Easyop::Plugins::Transactional
12
+ # end
13
+ #
14
+ # Usage — plugin DSL (recommended, works with ApplicationOperation):
15
+ # class ApplicationOperation
16
+ # include Easyop::Operation
17
+ # plugin Easyop::Plugins::Transactional
18
+ # end
19
+ #
20
+ # Or opt in per operation:
21
+ # class TransferFunds < ApplicationOperation
22
+ # plugin Easyop::Plugins::Transactional
23
+ # end
24
+ #
25
+ # To opt out when the parent has it:
26
+ # class ReadOnlyOp < ApplicationOperation
27
+ # transactional false
28
+ # end
29
+ module Transactional
30
+ # Support the `plugin` DSL: `plugin Easyop::Plugins::Transactional`
31
+ def self.install(base, **_options)
32
+ base.include(self)
33
+ end
34
+
35
+ def self.included(base)
36
+ base.extend(ClassMethods)
37
+ base.around(:_transactional_wrap)
38
+ end
39
+
40
+ module ClassMethods
41
+ # Opt out of transaction wrapping: `transactional false`
42
+ def transactional(enabled)
43
+ @_transactional_enabled = enabled
44
+ end
45
+
46
+ def _transactional_enabled?
47
+ return @_transactional_enabled if instance_variable_defined?(:@_transactional_enabled)
48
+ superclass.respond_to?(:_transactional_enabled?) ? superclass._transactional_enabled? : true
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def _transactional_wrap
55
+ return yield unless self.class._transactional_enabled?
56
+
57
+ db = if defined?(ActiveRecord::Base)
58
+ ActiveRecord::Base
59
+ elsif defined?(Sequel::Model)
60
+ Sequel::Model.db
61
+ else
62
+ raise "Easyop::Plugins::Transactional requires ActiveRecord or Sequel"
63
+ end
64
+
65
+ db.transaction { yield }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,68 @@
1
+ module Easyop
2
+ # Lightweight rescue_from DSL, modelled after ActiveSupport::Rescuable.
3
+ # Works without ActiveSupport and can be used standalone.
4
+ #
5
+ # Usage:
6
+ # rescue_from SomeError, with: :handle_it
7
+ # rescue_from OtherError, AnotherError do |e|
8
+ # ctx.fail!(error: e.message)
9
+ # end
10
+ #
11
+ # Handlers are checked in definition order (first match wins).
12
+ # Subclasses inherit their parent's handlers.
13
+ module Rescuable
14
+ def self.included(base)
15
+ base.extend(ClassMethods)
16
+ end
17
+
18
+ module ClassMethods
19
+ # Register a handler for one or more exception classes.
20
+ # Pass `with: :method_name` or a block.
21
+ def rescue_from(*klasses, with: nil, &block)
22
+ raise ArgumentError, "Provide `with:` or a block" unless with || block_given?
23
+
24
+ handler = with || block
25
+ klasses.each do |klass|
26
+ _rescue_handlers << [klass, handler]
27
+ end
28
+ end
29
+
30
+ # Own handlers defined directly on this class (not inherited).
31
+ def _rescue_handlers
32
+ @_rescue_handlers ||= []
33
+ end
34
+
35
+ # Full ordered list: own handlers first, then ancestors' (child wins).
36
+ def _all_rescue_handlers
37
+ parent = superclass
38
+ parent_handlers = parent.respond_to?(:_all_rescue_handlers) ? parent._all_rescue_handlers : []
39
+ _rescue_handlers + parent_handlers
40
+ end
41
+ end
42
+
43
+ # Attempt to handle `exception` with a registered handler.
44
+ # Returns true if handled, false if no matching handler found.
45
+ def rescue_with_handler(exception)
46
+ handler = handler_for_rescue(exception)
47
+ return false unless handler
48
+
49
+ case handler
50
+ when Symbol then send(handler, exception)
51
+ when Proc then instance_exec(exception, &handler)
52
+ end
53
+ true
54
+ end
55
+
56
+ private
57
+
58
+ def handler_for_rescue(exception)
59
+ self.class._all_rescue_handlers.each do |klass, handler|
60
+ klass_const = klass.is_a?(String) ? Object.const_get(klass) : klass
61
+ return handler if exception.is_a?(klass_const)
62
+ rescue NameError
63
+ next # constant not loaded yet — skip
64
+ end
65
+ nil
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,168 @@
1
+ module Easyop
2
+ # Optional typed schema DSL for Operation inputs and outputs.
3
+ #
4
+ # When included (automatically when you use `params` or `result` in an
5
+ # Operation), it adds before/after hooks that validate ctx against the
6
+ # declared schema using the configured type adapter.
7
+ #
8
+ # Usage:
9
+ # params do
10
+ # required :email, String
11
+ # required :amount, Integer
12
+ # optional :note, String
13
+ # optional :retry, :boolean, default: false
14
+ # end
15
+ #
16
+ # result do
17
+ # required :record, ActiveRecord::Base
18
+ # optional :token, String
19
+ # end
20
+ #
21
+ # Type symbols (:boolean, :string, :integer, :float) are mapped to native
22
+ # Ruby classes. Pass an actual class (String, User, etc.) for strict is_a?
23
+ # checking. Type validation only happens if Easyop.config.type_adapter != :none.
24
+ module Schema
25
+ def self.included(base)
26
+ base.extend(ClassMethods)
27
+ end
28
+
29
+ module ClassMethods
30
+ # Declare input schema. Validated before `call` runs.
31
+ def params(&block)
32
+ schema = FieldSchema.new
33
+ schema.instance_eval(&block)
34
+ @_param_schema = schema
35
+
36
+ # Register as a before hook (prepend so it runs first)
37
+ _before_hooks.unshift(:_validate_params!)
38
+ end
39
+ alias inputs params
40
+
41
+ # Declare output schema. Validated after `call` returns (not in ensure).
42
+ def result(&block)
43
+ schema = FieldSchema.new
44
+ schema.instance_eval(&block)
45
+ @_result_schema = schema
46
+
47
+ _after_hooks.push(:_validate_result!)
48
+ end
49
+ alias outputs result
50
+
51
+ def _param_schema
52
+ @_param_schema
53
+ end
54
+
55
+ def _result_schema
56
+ @_result_schema
57
+ end
58
+ end
59
+
60
+ # ── Instance validation methods ───────────────────────────────────────────
61
+
62
+ def _validate_params!
63
+ schema = self.class._param_schema
64
+ return unless schema
65
+ schema.validate!(ctx, phase: :params)
66
+ end
67
+
68
+ def _validate_result!
69
+ schema = self.class._result_schema
70
+ return unless schema && ctx.success?
71
+ schema.validate!(ctx, phase: :result)
72
+ end
73
+ end
74
+
75
+ # Describes a set of typed fields (used for both params and result schemas).
76
+ class FieldSchema
77
+ TYPE_MAP = {
78
+ string: String,
79
+ integer: Integer,
80
+ float: Float,
81
+ boolean: [TrueClass, FalseClass],
82
+ symbol: Symbol,
83
+ any: BasicObject,
84
+ }.freeze
85
+
86
+ Field = Struct.new(:name, :type, :required, :default, :has_default, keyword_init: true)
87
+
88
+ def initialize
89
+ @fields = []
90
+ end
91
+
92
+ def required(name, type = nil, **opts)
93
+ add_field(name, type, required: true, **opts)
94
+ end
95
+
96
+ def optional(name, type = nil, **opts)
97
+ add_field(name, type, required: false, **opts)
98
+ end
99
+
100
+ def fields
101
+ @fields.dup
102
+ end
103
+
104
+ # Validate ctx against this schema.
105
+ # Raises Ctx::Failure on hard errors; emits warnings otherwise
106
+ # depending on Easyop.config.strict_types.
107
+ def validate!(ctx, phase: :params)
108
+ @fields.each do |field|
109
+ val = ctx[field.name]
110
+
111
+ # Apply default if not set
112
+ if val.nil? && field.has_default
113
+ ctx[field.name] = field.default.respond_to?(:call) ? field.default.call : field.default
114
+ val = ctx[field.name]
115
+ end
116
+
117
+ # Required check
118
+ if field.required && val.nil?
119
+ ctx.fail!(
120
+ error: "Missing required #{phase} field: #{field.name}",
121
+ errors: ctx.errors.merge(field.name => "is required")
122
+ )
123
+ end
124
+
125
+ # Type check (skip if nil and optional)
126
+ next if val.nil?
127
+ next if field.type.nil?
128
+
129
+ type_check!(ctx, field, val, phase)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def add_field(name, type, required:, default: :__no_default__, **_rest)
136
+ resolved = resolve_type(type)
137
+ @fields << Field.new(
138
+ name: name.to_sym,
139
+ type: resolved,
140
+ required: required,
141
+ default: default == :__no_default__ ? nil : default,
142
+ has_default: default != :__no_default__
143
+ )
144
+ end
145
+
146
+ def resolve_type(type)
147
+ return nil if type.nil?
148
+ return type if type.is_a?(Class) || type.is_a?(Array)
149
+
150
+ TYPE_MAP[type.to_sym] || (raise ArgumentError, "Unknown type shorthand: #{type.inspect}")
151
+ end
152
+
153
+ def type_check!(ctx, field, val, phase)
154
+ types = Array(field.type)
155
+ valid = types.any? { |t| t == BasicObject || val.is_a?(t) }
156
+ return if valid
157
+
158
+ msg = "Type mismatch in #{phase} field :#{field.name} — " \
159
+ "expected #{types.map(&:name).join(" | ")}, got #{val.class}"
160
+
161
+ if Easyop.config.strict_types
162
+ ctx.fail!(error: msg, errors: ctx.errors.merge(field.name => msg))
163
+ else
164
+ warn "[Easyop] #{msg}"
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,22 @@
1
+ module Easyop
2
+ module Skip
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def skip_if(&block)
9
+ @_skip_predicate = block
10
+ end
11
+
12
+ def _skip_predicate
13
+ @_skip_predicate
14
+ end
15
+
16
+ # Returns true if this step should be skipped for the given ctx.
17
+ def skip?(ctx)
18
+ @_skip_predicate ? @_skip_predicate.call(ctx) : false
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,3 @@
1
+ module Easyop
2
+ VERSION = "0.1.0"
3
+ end
data/lib/easyop.rb ADDED
@@ -0,0 +1,41 @@
1
+ require_relative "easyop/version"
2
+ require_relative "easyop/configuration"
3
+ require_relative "easyop/ctx"
4
+ require_relative "easyop/hooks"
5
+ require_relative "easyop/rescuable"
6
+ require_relative "easyop/skip"
7
+ require_relative "easyop/schema"
8
+ require_relative "easyop/operation"
9
+ require_relative "easyop/flow_builder"
10
+ require_relative "easyop/flow"
11
+
12
+ # Optional plugins — not auto-required
13
+ # require_relative "easyop/plugins/transactional"
14
+
15
+ # Optional plugins — require explicitly or via Bundler:
16
+ # require "easyop/plugins/base"
17
+ # require "easyop/plugins/instrumentation"
18
+ # require "easyop/plugins/recording"
19
+ # require "easyop/plugins/async"
20
+
21
+ module Easyop
22
+ # Convenience: inherit from this instead of including Easyop::Operation
23
+ # when you want a common base class for all your operations.
24
+ #
25
+ # class ApplicationOperation
26
+ # include Easyop::Operation
27
+ #
28
+ # rescue_from StandardError, with: :handle_unexpected
29
+ #
30
+ # private
31
+ #
32
+ # def handle_unexpected(e)
33
+ # Sentry.capture_exception(e)
34
+ # ctx.fail!(error: "An unexpected error occurred")
35
+ # end
36
+ # end
37
+ #
38
+ # class MyOp < ApplicationOperation
39
+ # ...
40
+ # end
41
+ end