jidoka 0.2.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 856c4e4436542b548e463c61551160030fd4baf16c25c78a0ff1868f55b0193e
4
+ data.tar.gz: '0849949d4d82447b878a348d575bc36bba70c11ed64a987093a482a9627a575e'
5
+ SHA512:
6
+ metadata.gz: 56f3274112e8f9e1afb3ff0c0e9e35c670a2baf4248dd46eced87429e5f3f6286d11c08ab9d79ca8f453d58a29f86763e3baa23800a11168ae94dfad068e159c
7
+ data.tar.gz: 17acb1f17bf45281cc1265a883bf44772270a5c0bfe24e904109dbb0af32f6ae778454652b55a9f2cb1cdb4db82a1ec5bd04360221372178d917c5d51fefe492
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+ .idea
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.2
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.6.3
7
+ before_install: gem install bundler -v 1.17.2
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ # Specify your gem's dependencies in jidoka.gemspec
8
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,125 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ jidoka (0.2.0)
5
+ activejob (>= 6.0)
6
+ activerecord (>= 6.0)
7
+ activesupport (>= 6.0)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ activejob (8.1.2)
13
+ activesupport (= 8.1.2)
14
+ globalid (>= 0.3.6)
15
+ activemodel (8.1.2)
16
+ activesupport (= 8.1.2)
17
+ activerecord (8.1.2)
18
+ activemodel (= 8.1.2)
19
+ activesupport (= 8.1.2)
20
+ timeout (>= 0.4.0)
21
+ activesupport (8.1.2)
22
+ base64
23
+ bigdecimal
24
+ concurrent-ruby (~> 1.0, >= 1.3.1)
25
+ connection_pool (>= 2.2.5)
26
+ drb
27
+ i18n (>= 1.6, < 2)
28
+ json
29
+ logger (>= 1.4.2)
30
+ minitest (>= 5.1)
31
+ securerandom (>= 0.3)
32
+ tzinfo (~> 2.0, >= 2.0.5)
33
+ uri (>= 0.13.1)
34
+ base64 (0.3.0)
35
+ bigdecimal (4.0.1)
36
+ coderay (1.1.3)
37
+ concurrent-ruby (1.3.6)
38
+ connection_pool (3.0.2)
39
+ diff-lcs (1.6.2)
40
+ drb (2.2.3)
41
+ globalid (1.3.0)
42
+ activesupport (>= 6.1)
43
+ i18n (1.14.8)
44
+ concurrent-ruby (~> 1.0)
45
+ io-console (0.8.2)
46
+ json (2.18.0)
47
+ logger (1.7.0)
48
+ method_source (1.1.0)
49
+ minitest (6.0.1)
50
+ prism (~> 1.5)
51
+ prism (1.9.0)
52
+ pry (0.16.0)
53
+ coderay (~> 1.1)
54
+ method_source (~> 1.0)
55
+ reline (>= 0.6.0)
56
+ rake (13.3.1)
57
+ reline (0.6.3)
58
+ io-console (~> 0.5)
59
+ rspec (3.13.2)
60
+ rspec-core (~> 3.13.0)
61
+ rspec-expectations (~> 3.13.0)
62
+ rspec-mocks (~> 3.13.0)
63
+ rspec-core (3.13.6)
64
+ rspec-support (~> 3.13.0)
65
+ rspec-expectations (3.13.5)
66
+ diff-lcs (>= 1.2.0, < 2.0)
67
+ rspec-support (~> 3.13.0)
68
+ rspec-mocks (3.13.7)
69
+ diff-lcs (>= 1.2.0, < 2.0)
70
+ rspec-support (~> 3.13.0)
71
+ rspec-support (3.13.7)
72
+ securerandom (0.4.1)
73
+ sqlite3 (2.9.0-arm64-darwin)
74
+ timeout (0.6.0)
75
+ tzinfo (2.0.6)
76
+ concurrent-ruby (~> 1.0)
77
+ uri (1.1.1)
78
+
79
+ PLATFORMS
80
+ arm64-darwin-23
81
+
82
+ DEPENDENCIES
83
+ jidoka!
84
+ pry
85
+ rake (~> 13.0)
86
+ rspec (~> 3.0)
87
+ sqlite3
88
+
89
+ CHECKSUMS
90
+ activejob (8.1.2) sha256=908dab3713b101859536375819f4156b07bdf4c232cc645e7538adb9e302f825
91
+ activemodel (8.1.2) sha256=e21358c11ce68aed3f9838b7e464977bc007b4446c6e4059781e1d5c03bcf33e
92
+ activerecord (8.1.2) sha256=acfbe0cadfcc50fa208011fe6f4eb01cae682ebae0ef57145ba45380c74bcc44
93
+ activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae
94
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
95
+ bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
96
+ coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
97
+ concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
98
+ connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
99
+ diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
100
+ drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
101
+ globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
102
+ i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
103
+ io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
104
+ jidoka (0.2.0)
105
+ json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
106
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
107
+ method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
108
+ minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
109
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
110
+ pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
111
+ rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
112
+ reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
113
+ rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
114
+ rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
115
+ rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
116
+ rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c
117
+ rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
118
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
119
+ sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3
120
+ timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
121
+ tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
122
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
123
+
124
+ BUNDLED WITH
125
+ 4.0.5
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Nate Mortensen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Jidoka
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/jidoka`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'jidoka'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install jidoka
22
+
23
+ ## Usage
24
+
25
+ Add an initializer to configure Jidoka:
26
+
27
+ ```ruby
28
+ # config/initializers/jidoka.rb
29
+ Jidoka.configure do |config|
30
+ # Inherit from your app's base job to get queues, retries, etc.
31
+ config.parent_job_class = "ApplicationJob"
32
+
33
+ # Hook into your error reporting tool
34
+ config.error_handler = ->(e, context) {
35
+ if defined?(Sentry)
36
+ Sentry.set_context('jidoka', context)
37
+ Sentry.capture_exception(e)
38
+ Sentry::Context.clear!
39
+ else
40
+ Rails.logger.error("Jidoka Error: #{e.message} Context: #{context}")
41
+ end
42
+ }
43
+ end
44
+ ```
45
+
46
+ ## Development
47
+
48
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
49
+
50
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
51
+
52
+ ## Contributing
53
+
54
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/jidoka.
55
+
56
+ ## License
57
+
58
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'jidoka'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/jidoka.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/jidoka/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "jidoka"
7
+ spec.version = Jidoka::VERSION
8
+ spec.authors = ["Nate Mortensen"]
9
+ spec.summary = "Reversible Command and Orchestrator patterns for Rails."
10
+ spec.description = "Encapsulate complex business logic with automatic rollback, validation, and background processing."
11
+ spec.homepage = "https://github.com/natemortensen/jidoka"
12
+ spec.license = "MIT"
13
+
14
+ spec.metadata["rubygems_mfa_required"] = "true"
15
+
16
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
17
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
18
+ end
19
+
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.required_ruby_version = ">= 3.0.0"
25
+
26
+ spec.add_dependency "activejob", ">= 6.0"
27
+ spec.add_dependency "activerecord", ">= 6.0"
28
+ spec.add_dependency "activesupport", ">= 6.0"
29
+
30
+ spec.add_development_dependency "rspec", "~> 3.0"
31
+ spec.add_development_dependency "rake", "~> 13.0"
32
+ spec.add_development_dependency "pry"
33
+ spec.add_development_dependency "sqlite3"
34
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jidoka
4
+ # Raised when rollback (down) fails
5
+ class IrreversibleAction < StandardError; end
6
+
7
+ # Raised when enforce_arguments! validation fails
8
+ class ArgumentClassMismatch < StandardError
9
+ attr_reader :param, :expected, :actual
10
+
11
+ def initialize(argument, expected:, actual:)
12
+ @param = argument
13
+ @expected = expected
14
+ @actual = actual
15
+ super("#{param} was expected to be a(n) #{@expected} but was a(n) #{@actual || 'nil'}")
16
+ end
17
+ end
18
+
19
+ # Raised when business logic conditions are not met (Validation phase)
20
+ class ConditionNotMet < StandardError
21
+ attr_reader :code
22
+
23
+ def initialize(code:, message:)
24
+ @code = code
25
+ super(message)
26
+ end
27
+ end
28
+
29
+ # Raised when execution fails for a known reason (Execution phase)
30
+ class Failure < StandardError
31
+ attr_reader :code, :context
32
+
33
+ def initialize(code:, message:, context: {})
34
+ @code = code
35
+ @context = context
36
+ super(message)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jidoka
4
+ module Notifiable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ def send_notification
9
+ notify(@opts)
10
+
11
+ # These are just notifications so they can silently fail but still report error
12
+ rescue StandardError => e
13
+ capture_error(e)
14
+ end
15
+
16
+ def notify(**_opts); end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ module Jidoka
2
+ class Supervisor < Commander
3
+ class Step
4
+ attr_reader :result
5
+
6
+ def initialize(caller)
7
+ @caller = caller
8
+ end
9
+
10
+ def up(&block)
11
+ @result = @caller.instance_eval(&block)
12
+ end
13
+
14
+ def _down
15
+ @caller.instance_exec(@result, &@down) if @down.present?
16
+ end
17
+
18
+ def down(&block)
19
+ @down = block
20
+ end
21
+
22
+ def _notify
23
+ @caller.instance_exec(@result, &@notify) if @notify.present?
24
+ end
25
+
26
+ def notify(&block)
27
+ @notify = block
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,94 @@
1
+ require_relative "supervisor/step"
2
+
3
+ module Jidoka
4
+ class Supervisor < Commander
5
+ def initialize(*args)
6
+ super(*args)
7
+ @steps = []
8
+ end
9
+
10
+ def run!
11
+ orchestrate(**@opts)
12
+ rescue StandardError => e
13
+ rollback
14
+ # notice_failure! is called in run! wrapper of super, but we need to ensure
15
+ # we notice it here if we want to log it before re-raising
16
+ send(:notice_failure!, e)
17
+ raise(e)
18
+ end
19
+
20
+ def rollback
21
+ @steps.reverse_each do |step|
22
+ begin
23
+ step._down
24
+ rescue StandardError => e
25
+ report_error(e)
26
+ end
27
+ end
28
+ end
29
+
30
+ # Alias for compatibility
31
+ def down
32
+ rollback
33
+ end
34
+
35
+ def notify!
36
+ @steps.each(&:_notify)
37
+ _notify(**@opts)
38
+ rescue StandardError => e
39
+ report_error(e)
40
+ raise(e) if defined?(Rails) && Rails.env.test?
41
+ end
42
+
43
+ protected
44
+
45
+ def step!(&block)
46
+ step = Step.new(self)
47
+ begin
48
+ step.instance_eval(&block)
49
+ rescue StandardError => e
50
+ @message = e.message
51
+ raise(e)
52
+ end
53
+
54
+ @steps << step
55
+ step.result
56
+ end
57
+
58
+ def worker_step!(klass, opts = {})
59
+ step! do
60
+ up { klass.run!(**opts.merge(notify: false)) }
61
+ down(&:down) # Calls down on the worker instance returned by up
62
+ notify(&:notify!) unless opts[:notify] == false
63
+ end
64
+ end
65
+
66
+ def update_record_step!(record, updates)
67
+ previous_state = updates.to_h do |k, v|
68
+ [
69
+ k,
70
+ k.to_s =~ /_attributes/ ? v.class.new : record.send(k)
71
+ ]
72
+ end
73
+
74
+ step! do
75
+ up { record.tap { record.update!(updates) } }
76
+ down { record.tap { record.update!(previous_state) } }
77
+ end
78
+ end
79
+
80
+ def create_record_step!(&block)
81
+ step! do
82
+ up(&block)
83
+ down do |record|
84
+ record.reload.destroy!
85
+ end
86
+ end
87
+ end
88
+
89
+ # Abstract method to be implemented by subclass
90
+ def orchestrate(**_opts)
91
+ raise NotImplementedError
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jidoka
4
+ module Validatable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ attr_reader :message, :error
9
+
10
+ ##
11
+ # This is a Hash of required argument keys and their expected class
12
+ class_attribute :argument_types, :errors
13
+
14
+ def validate!
15
+ validate_arguments!(@opts)
16
+ prepare(@opts)
17
+ audit(@opts)
18
+ rescue ConditionNotMet, ArgumentClassMismatch, Failure => e
19
+ notice_failure(e)
20
+ raise(e)
21
+ end
22
+
23
+ def validate
24
+ validate!
25
+ rescue StandardError => e
26
+ notice_failure(e)
27
+ end
28
+
29
+ def notice_failure(e)
30
+ @failure = true
31
+ @error = e
32
+ @message = e.message
33
+
34
+ capture_error(error)
35
+ end
36
+
37
+ def capture_error(error); end
38
+
39
+ def failure?
40
+ !@failure.nil?
41
+ end
42
+
43
+ def failed
44
+ yield(self) if failure?
45
+ end
46
+
47
+ def success?
48
+ !failure?
49
+ end
50
+
51
+ def succeeded
52
+ yield(self) if success?
53
+ end
54
+
55
+ def audit(**_opts); end
56
+
57
+ ##
58
+ # Raises `ConditionNotMet` with specified error message if block does not return a truthy value.
59
+ # Intended for use with `audit`
60
+ def valid_if(error_key, message: nil)
61
+ raise_condition!(error_key, message: message) unless yield
62
+ end
63
+
64
+ ##
65
+ # Raises `ConditionNotMet` with specified error message if block does not return a falsey value.
66
+ # Intended for use with `audit`
67
+ def valid_unless(error_key, message: nil)
68
+ raise_condition!(error_key, message: message) if yield
69
+ end
70
+
71
+ def raise_condition!(key, message: nil)
72
+ raise ConditionNotMet.new(
73
+ message: message || self.class.possible_errors[key],
74
+ code: key
75
+ )
76
+ end
77
+
78
+ def fail!(key, message: nil)
79
+ raise Failure.new(
80
+ message: message || self.class.possible_errors[key],
81
+ code: key
82
+ )
83
+ end
84
+
85
+ # Iterate through `argument_types` checking for a class match
86
+ def validate_arguments!(**args)
87
+ return if self.class.argument_types.nil?
88
+
89
+ self.class.argument_types.each do |key, klass|
90
+ case args[key]
91
+ when *Array(klass).map(&:constantize) then nil # Object is okay
92
+ else raise ArgumentClassMismatch.new(key, expected: klass, actual: args[key]&.class&.to_s)
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ class_methods do
99
+ def enforce_arguments!(obj)
100
+ self.argument_types = obj.freeze
101
+ end
102
+
103
+ def set_errors(obj)
104
+ self.errors = (errors || {}).merge(obj)
105
+ end
106
+
107
+ def possible_errors(with_prefix = false)
108
+ @possible_errors ||= errors || {}
109
+ with_prefix ? @possible_errors.transform_keys { |k| [to_s.underscore, k].join('-') } : @possible_errors
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jidoka
4
+ VERSION = '0.2.0'
5
+ end
@@ -0,0 +1,239 @@
1
+ module Jidoka
2
+ class Commander < Jidoka.configuration.parent_job_class.constantize
3
+ include ActiveSupport::Rescuable
4
+
5
+ # @return [String] Error message to display to end users
6
+ attr_reader :message, :error
7
+
8
+ class_attribute :argument_types
9
+
10
+ # Shared error messages available to all Commanders
11
+ BASE_ERRORS = {
12
+ invalid_state_transition: 'You cannot transition to this state',
13
+ action_already_performed: 'This action has already been performed'
14
+ }.freeze
15
+
16
+ # Default ERRORS hash to be overridden by subclasses
17
+ ERRORS = {}.freeze
18
+
19
+ # -- Class Methods --
20
+
21
+ def self.enforce_arguments!(obj)
22
+ self.argument_types = obj.freeze
23
+ end
24
+
25
+ def self.possible_errors(with_prefix = false)
26
+ @possible_errors ||= self::ERRORS
27
+ with_prefix ? @possible_errors.transform_keys { |k| [to_s.underscore, k].join('-') } : @possible_errors
28
+ end
29
+
30
+ def self.run!(opts = {})
31
+ initialize_and_call!(opts, :validate!, :run!, *include_notify?(opts))
32
+ end
33
+
34
+ def self.run(opts = {})
35
+ initialize_and_call!(opts, :validate, :run, *include_notify?(opts)).tap do |result|
36
+ yield(result) if block_given?
37
+ end
38
+ end
39
+
40
+ def self.dry_run!(opts = {})
41
+ initialize_and_call!(opts, :validate!)
42
+ end
43
+
44
+ def self.dry_run(opts = {})
45
+ initialize_and_call!(opts, :validate)
46
+ end
47
+
48
+ def self.undo!(opts = {})
49
+ initialize_and_call!(opts, :undo!)
50
+ end
51
+
52
+ def self.undo(opts = {})
53
+ initialize_and_call!(opts, :undo).tap do |result|
54
+ yield(result) if block_given?
55
+ end
56
+ end
57
+
58
+ def self.include_notify?(opts)
59
+ [nil, true].include?(opts.delete(:notify)) ? %i[notify!] : []
60
+ end
61
+
62
+ def self.initialize_and_call!(opts, *methods_to_call)
63
+ instance = new(opts.transform_keys(&:to_sym))
64
+ methods_to_call.each do |m|
65
+ instance.send(m)
66
+ break if instance.failure?
67
+ end
68
+ instance
69
+ end
70
+
71
+ # -- Instance Methods --
72
+
73
+ def initialize(args = nil)
74
+ super
75
+ # Handle ActiveJob vs direct instantiation args
76
+ @opts = args || (arguments ? arguments[0] : {})
77
+ @opts = @opts.transform_keys(&:to_sym) if @opts
78
+ end
79
+
80
+ def perform(opts = {})
81
+ @opts = opts.transform_keys(&:to_sym)
82
+ validate!
83
+ run!
84
+ notify!
85
+ end
86
+
87
+ def prepare(opts); end
88
+
89
+ def validate!
90
+ validate_arguments!(**@opts)
91
+ prepare(**@opts)
92
+ validate_conditions!(**@opts)
93
+ rescue Jidoka::ConditionNotMet, Jidoka::ArgumentClassMismatch, Jidoka::Failure => e
94
+ notice_failure!(e)
95
+ raise(e)
96
+ end
97
+
98
+ def validate
99
+ validate!
100
+ rescue StandardError => e
101
+ notice_failure!(e)
102
+ end
103
+
104
+ def run!
105
+ ActiveRecord::Base.transaction { up(**@opts) }
106
+ rescue Jidoka::ConditionNotMet, Jidoka::Failure => e
107
+ notice_failure!(e)
108
+ raise(e)
109
+ end
110
+
111
+ def run
112
+ run!
113
+ rescue StandardError => e
114
+ notice_failure!(e)
115
+ end
116
+
117
+ def undo!
118
+ prepare_inverse(**@opts) if respond_to?(:prepare_inverse)
119
+ ActiveRecord::Base.transaction { down }
120
+ rescue Jidoka::ConditionNotMet, Jidoka::Failure => e
121
+ notice_failure!(e)
122
+ raise(e)
123
+ end
124
+
125
+ def undo
126
+ undo!
127
+ rescue StandardError => e
128
+ notice_failure!(e)
129
+ end
130
+
131
+ def up(_opts = nil)
132
+ raise NotImplementedError
133
+ end
134
+
135
+ def down; end
136
+
137
+ def notify!
138
+ _notify(**@opts)
139
+ rescue StandardError => e
140
+ report_error(e)
141
+ # We do not re-raise notification errors by default unless in test
142
+ raise(e) if defined?(Rails) && Rails.env.test?
143
+ end
144
+
145
+ def _notify(**_opts); end
146
+
147
+ # -- State Helpers --
148
+
149
+ def failure?
150
+ @failure.present?
151
+ end
152
+
153
+ def success?
154
+ !failure?
155
+ end
156
+
157
+ def failed
158
+ yield(self) if failure?
159
+ end
160
+
161
+ def success
162
+ yield(self) if success?
163
+ end
164
+
165
+ protected
166
+
167
+ def validate_conditions!(**opts); end
168
+
169
+ def condition!(key, message: nil)
170
+ raise_condition!(key, message: message) unless yield
171
+ end
172
+
173
+ def raise_condition!(key, message: nil)
174
+ raise Jidoka::ConditionNotMet.new(
175
+ message: message || self.class.possible_errors[key],
176
+ code: [self.class.to_s.underscore, key].join('-')
177
+ )
178
+ end
179
+
180
+ def fail!(key, message: nil, **context)
181
+ raise Jidoka::Failure.new(
182
+ message: message || self.class.possible_errors[key],
183
+ code: [self.class.to_s.underscore, key].join('-'),
184
+ context: context
185
+ )
186
+ end
187
+
188
+ def validate_arguments!(**args)
189
+ return if self.class.argument_types.nil?
190
+
191
+ self.class.argument_types.each do |key, klass_name|
192
+ val = args[key]
193
+ expected_classes = Array(klass_name).map(&:constantize)
194
+
195
+ # Check if value matches any of the expected classes
196
+ unless expected_classes.any? { |k| val.is_a?(k) }
197
+ raise Jidoka::ArgumentClassMismatch.new(key, expected: klass_name, actual: val.class.to_s)
198
+ end
199
+ end
200
+ end
201
+
202
+ # Validates usage of AASM state machines if AASM is present
203
+ def aasm_event_condition!(object, event)
204
+ return unless defined?(AASM)
205
+
206
+ matched = object.class.aasm.events.detect { |e| e.name.to_s == event.to_s }
207
+ return if matched.may_fire?(object)
208
+
209
+ if matched.transitions[0].to == object.aasm.current_state
210
+ raise_condition!(
211
+ :action_already_performed,
212
+ message: "This #{object.class.to_s.humanize} is already #{object.try(:status)&.humanize || 'in that state'}"
213
+ )
214
+ else
215
+ raise_condition!(
216
+ :invalid_state_transition,
217
+ message: "Could not #{event} because current status is #{object.try(:status)&.humanize || 'invalid'}"
218
+ )
219
+ end
220
+ end
221
+
222
+ def valid_for?(record, context)
223
+ record.valid?(context) || raise(ActiveRecord::RecordInvalid, record)
224
+ end
225
+
226
+ private
227
+
228
+ def notice_failure!(e)
229
+ @failure = true
230
+ @error = e
231
+ @message = e.message
232
+ report_error(e)
233
+ end
234
+
235
+ def report_error(e)
236
+ Jidoka.configuration.error_handler.call(e, { worker: self.class.name, args: @opts })
237
+ end
238
+ end
239
+ end
data/lib/jidoka.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_job"
5
+ require "active_record"
6
+ require "jidoka/version"
7
+ require "jidoka/errors"
8
+
9
+ module Jidoka
10
+ class Error < StandardError; end
11
+
12
+ class Configuration
13
+ # The parent class for Workers (defaults to ActiveJob::Base)
14
+ attr_accessor :parent_job_class
15
+ # Block to execute when an error occurs (for Sentry/Honeybadger)
16
+ attr_accessor :error_handler
17
+
18
+ def initialize
19
+ @parent_job_class = "ActiveJob::Base"
20
+ @error_handler = ->(error, context = {}) {
21
+ # Default: just log it
22
+ if defined?(Rails)
23
+ Rails.logger.error("[Jidoka] #{error.message}")
24
+ Rails.logger.error(error.backtrace.join("\n"))
25
+ end
26
+ }
27
+ end
28
+ end
29
+
30
+ class << self
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ def configure
36
+ yield(configuration)
37
+ end
38
+ end
39
+ end
40
+
41
+ require "jidoka/worker"
42
+ require "jidoka/supervisor"
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jidoka
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Nate Mortensen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-02-12 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activejob
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '6.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activesupport
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '6.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '6.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: pry
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: sqlite3
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ description: Encapsulate complex business logic with automatic rollback, validation,
111
+ and background processing.
112
+ executables: []
113
+ extensions: []
114
+ extra_rdoc_files: []
115
+ files:
116
+ - ".gitignore"
117
+ - ".rspec"
118
+ - ".ruby-version"
119
+ - ".travis.yml"
120
+ - Gemfile
121
+ - Gemfile.lock
122
+ - LICENSE.txt
123
+ - README.md
124
+ - Rakefile
125
+ - bin/console
126
+ - bin/setup
127
+ - jidoka.gemspec
128
+ - lib/jidoka.rb
129
+ - lib/jidoka/errors.rb
130
+ - lib/jidoka/notifiable.rb
131
+ - lib/jidoka/supervisor.rb
132
+ - lib/jidoka/supervisor/step.rb
133
+ - lib/jidoka/validatable.rb
134
+ - lib/jidoka/version.rb
135
+ - lib/jidoka/worker.rb
136
+ homepage: https://github.com/natemortensen/jidoka
137
+ licenses:
138
+ - MIT
139
+ metadata:
140
+ rubygems_mfa_required: 'true'
141
+ rdoc_options: []
142
+ require_paths:
143
+ - lib
144
+ required_ruby_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: 3.0.0
149
+ required_rubygems_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
154
+ requirements: []
155
+ rubygems_version: 3.6.2
156
+ specification_version: 4
157
+ summary: Reversible Command and Orchestrator patterns for Rails.
158
+ test_files: []