standard_procedure_operations 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9cfaaef3f5470debfff84e64763fd703752ae0c70a7c62c2926468aa9b897c83
4
+ data.tar.gz: 2af99adbc94c3c6f734e9272b909d42d199ddc7d7f18a866f4d14bd2ad78b9ff
5
+ SHA512:
6
+ metadata.gz: 40ba366b5f54cfd1d376ac34731f1d8e462afffef0512b9011603734a3c45319e40994b7790f556e6bfd27923b0cf67aadd521bebf98f004b71d7aad5956c53f
7
+ data.tar.gz: 833099833d7ef03773530f4301ee5b66434c26c94af54508e0c006aa082e6d4c812b84fd621dd9e5d9cb9a300be17292bf4be7c46679f5429c50937d5362a94b
data/LICENSE ADDED
@@ -0,0 +1,165 @@
1
+ GNU LESSER GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+
9
+ This version of the GNU Lesser General Public License incorporates
10
+ the terms and conditions of version 3 of the GNU General Public
11
+ License, supplemented by the additional permissions listed below.
12
+
13
+ 0. Additional Definitions.
14
+
15
+ As used herein, "this License" refers to version 3 of the GNU Lesser
16
+ General Public License, and the "GNU GPL" refers to version 3 of the GNU
17
+ General Public License.
18
+
19
+ "The Library" refers to a covered work governed by this License,
20
+ other than an Application or a Combined Work as defined below.
21
+
22
+ An "Application" is any work that makes use of an interface provided
23
+ by the Library, but which is not otherwise based on the Library.
24
+ Defining a subclass of a class defined by the Library is deemed a mode
25
+ of using an interface provided by the Library.
26
+
27
+ A "Combined Work" is a work produced by combining or linking an
28
+ Application with the Library. The particular version of the Library
29
+ with which the Combined Work was made is also called the "Linked
30
+ Version".
31
+
32
+ The "Minimal Corresponding Source" for a Combined Work means the
33
+ Corresponding Source for the Combined Work, excluding any source code
34
+ for portions of the Combined Work that, considered in isolation, are
35
+ based on the Application, and not on the Linked Version.
36
+
37
+ The "Corresponding Application Code" for a Combined Work means the
38
+ object code and/or source code for the Application, including any data
39
+ and utility programs needed for reproducing the Combined Work from the
40
+ Application, but excluding the System Libraries of the Combined Work.
41
+
42
+ 1. Exception to Section 3 of the GNU GPL.
43
+
44
+ You may convey a covered work under sections 3 and 4 of this License
45
+ without being bound by section 3 of the GNU GPL.
46
+
47
+ 2. Conveying Modified Versions.
48
+
49
+ If you modify a copy of the Library, and, in your modifications, a
50
+ facility refers to a function or data to be supplied by an Application
51
+ that uses the facility (other than as an argument passed when the
52
+ facility is invoked), then you may convey a copy of the modified
53
+ version:
54
+
55
+ a) under this License, provided that you make a good faith effort to
56
+ ensure that, in the event an Application does not supply the
57
+ function or data, the facility still operates, and performs
58
+ whatever part of its purpose remains meaningful, or
59
+
60
+ b) under the GNU GPL, with none of the additional permissions of
61
+ this License applicable to that copy.
62
+
63
+ 3. Object Code Incorporating Material from Library Header Files.
64
+
65
+ The object code form of an Application may incorporate material from
66
+ a header file that is part of the Library. You may convey such object
67
+ code under terms of your choice, provided that, if the incorporated
68
+ material is not limited to numerical parameters, data structure
69
+ layouts and accessors, or small macros, inline functions and templates
70
+ (ten or fewer lines in length), you do both of the following:
71
+
72
+ a) Give prominent notice with each copy of the object code that the
73
+ Library is used in it and that the Library and its use are
74
+ covered by this License.
75
+
76
+ b) Accompany the object code with a copy of the GNU GPL and this license
77
+ document.
78
+
79
+ 4. Combined Works.
80
+
81
+ You may convey a Combined Work under terms of your choice that,
82
+ taken together, effectively do not restrict modification of the
83
+ portions of the Library contained in the Combined Work and reverse
84
+ engineering for debugging such modifications, if you also do each of
85
+ the following:
86
+
87
+ a) Give prominent notice with each copy of the Combined Work that
88
+ the Library is used in it and that the Library and its use are
89
+ covered by this License.
90
+
91
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
92
+ document.
93
+
94
+ c) For a Combined Work that displays copyright notices during
95
+ execution, include the copyright notice for the Library among
96
+ these notices, as well as a reference directing the user to the
97
+ copies of the GNU GPL and this license document.
98
+
99
+ d) Do one of the following:
100
+
101
+ 0) Convey the Minimal Corresponding Source under the terms of this
102
+ License, and the Corresponding Application Code in a form
103
+ suitable for, and under terms that permit, the user to
104
+ recombine or relink the Application with a modified version of
105
+ the Linked Version to produce a modified Combined Work, in the
106
+ manner specified by section 6 of the GNU GPL for conveying
107
+ Corresponding Source.
108
+
109
+ 1) Use a suitable shared library mechanism for linking with the
110
+ Library. A suitable mechanism is one that (a) uses at run time
111
+ a copy of the Library already present on the user's computer
112
+ system, and (b) will operate properly with a modified version
113
+ of the Library that is interface-compatible with the Linked
114
+ Version.
115
+
116
+ e) Provide Installation Information, but only if you would otherwise
117
+ be required to provide such information under section 6 of the
118
+ GNU GPL, and only to the extent that such information is
119
+ necessary to install and execute a modified version of the
120
+ Combined Work produced by recombining or relinking the
121
+ Application with a modified version of the Linked Version. (If
122
+ you use option 4d0, the Installation Information must accompany
123
+ the Minimal Corresponding Source and Corresponding Application
124
+ Code. If you use option 4d1, you must provide the Installation
125
+ Information in the manner specified by section 6 of the GNU GPL
126
+ for conveying Corresponding Source.)
127
+
128
+ 5. Combined Libraries.
129
+
130
+ You may place library facilities that are a work based on the
131
+ Library side by side in a single library together with other library
132
+ facilities that are not Applications and are not covered by this
133
+ License, and convey such a combined library under terms of your
134
+ choice, if you do both of the following:
135
+
136
+ a) Accompany the combined library with a copy of the same work based
137
+ on the Library, uncombined with any other library facilities,
138
+ conveyed under the terms of this License.
139
+
140
+ b) Give prominent notice with the combined library that part of it
141
+ is a work based on the Library, and explaining where to find the
142
+ accompanying uncombined form of the same work.
143
+
144
+ 6. Revised Versions of the GNU Lesser General Public License.
145
+
146
+ The Free Software Foundation may publish revised and/or new versions
147
+ of the GNU Lesser General Public License from time to time. Such new
148
+ versions will be similar in spirit to the present version, but may
149
+ differ in detail to address new problems or concerns.
150
+
151
+ Each version is given a distinguishing version number. If the
152
+ Library as you received it specifies that a certain numbered version
153
+ of the GNU Lesser General Public License "or any later version"
154
+ applies to it, you have the option of following the terms and
155
+ conditions either of that published version or of any later version
156
+ published by the Free Software Foundation. If the Library as you
157
+ received it does not specify a version number of the GNU Lesser
158
+ General Public License, you may choose any version of the GNU Lesser
159
+ General Public License ever published by the Free Software Foundation.
160
+
161
+ If the Library as you received it specifies that a proxy can decide
162
+ whether future versions of the GNU Lesser General Public License shall
163
+ apply, that proxy's public statement of acceptance of any version is
164
+ permanent authorization for you to choose that version for the
165
+ Library.
data/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # Operations
2
+ Build your business logic operations in an easy to understand format.
3
+
4
+ Most times when I'm adding a feature to a complex application, I tend to end up drawing a flowchart.
5
+
6
+ "We start here, then we check that option and if it's true then we do this, if it's false then we do that"
7
+
8
+ In effect, that flowchart is a state machine - with "decision states" and "action states". And Operations is intended to be a way of designing your ruby class so that flowchart becomes easy to follow.
9
+
10
+ ## Usage
11
+ Here's a simplified example from [Collabor8Online](https://www.collabor8online.co.uk) - in C8O when you download a document, we need to check your access rights, as well as ensuring that the current user has not breached their monthly download limit. In addition, some accounts have a "filename scrambler" switched on - where the original filename is replaced (which is a feature used by some of our clients on their customers' trial accounts).
12
+
13
+ ### Defining an operation
14
+ The flowchart, for this simplified example, is something like this:
15
+
16
+ ```
17
+ START -> CHECK AUTHORISATION
18
+ Is this user authorised?
19
+ NO -> FAIL
20
+ YES -> CHECK DOWNLOAD LIMITS
21
+
22
+ CHECK DOWNLOAD LIMITS
23
+ Is this user within their monthly download limit?
24
+ NO -> FAIL
25
+ YES -> CHECK FILENAME SCRAMBLER
26
+
27
+ CHECK FILENAME SCRAMBLER
28
+ Is the filename scrambler switched on for this account?
29
+ NO -> PREPARE DOWNLOAD
30
+ YES -> SCRAMBLE FILENAME
31
+
32
+ SCRAMBLE FILENAME
33
+ Replace the filename with a scrambled one
34
+ THEN -> PREPARE DOWNLOAD
35
+
36
+ PREPARE DOWNLOAD
37
+ Return the document's filename so it can be used when sending the document to the end user
38
+ DONE
39
+ ```
40
+
41
+ We have five states - three of which are decisions, one is an action and one is a result.
42
+
43
+ Here's how this would be represented using Operations.
44
+
45
+ ```ruby
46
+ class PrepareDocumentForDownload < Operations::Task
47
+ starts_with :authorised?
48
+
49
+ decision :authorised? do
50
+ if_true :within_download_limits?
51
+ if_false { fail_with "unauthorised" }
52
+ end
53
+
54
+ decision :within_download_limits? do
55
+ if_true :use_filename_scrambler?
56
+ if_false { fail_with "download_limit_reached" }
57
+ end
58
+
59
+ decision :use_filename_scrambler? do
60
+ condition { use_filename_scrambler }
61
+ if_true :scramble_filename
62
+ if_false :return_filename
63
+ end
64
+
65
+ action :scramble_filename do
66
+ self.filename = "#{Faker::Lorem.word}#{File.extname(document.filename.to_s)}"
67
+ go_to :return_filename
68
+ end
69
+
70
+ result :return_filename do |results|
71
+ results.filename = filename || document.filename.to_s
72
+ end
73
+
74
+ private def authorised?(data) = data.user.can?(:read, data.document)
75
+ private def within_download_limits?(data) = data.user.within_download_limits?
76
+ end
77
+ ```
78
+
79
+ The five states are represented as three [decision](#decisions) handlers, one [action](#actions) handler and a [result](#results) handler.
80
+
81
+ ### Decisions
82
+ A decision handler evaluates a condition, then changes state depending upon if the result is true or false.
83
+
84
+ It's up to you whether you define the condition as a block, as part of the decision handler, or as a method on the task object.
85
+
86
+ ```ruby
87
+ decision :is_it_the_weekend? do
88
+ condition { Date.today.wday.in? [0, 6] }
89
+ if_true :have_a_party
90
+ if_false :go_to_work
91
+ end
92
+ ```
93
+ Or
94
+ ```ruby
95
+ decision :is_it_the_weekend? do
96
+ if_true :have_a_party
97
+ if_false :go_to_work
98
+ end
99
+
100
+ def is_it_the_weekend?(data)
101
+ Date.today.wday.in? [0, 6]
102
+ end
103
+ ```
104
+ A decision can also mark a failure, which will terminate the task.
105
+ ```ruby
106
+ decision :authorised? do
107
+ condition { user.administrator? }
108
+ if_true :do_some_work
109
+ if_false { fail_with "Unauthorised" }
110
+ end
111
+ ```
112
+
113
+ ### Actions
114
+ An action handler does some work, then moves to another state.
115
+
116
+ ```ruby
117
+ action :have_a_party do
118
+ self.food = task.buy_some_food_for(number_of_guests)
119
+ self.beer = task.buy_some_beer_for(number_of_guests)
120
+ self.music = task.plan_a_party_playlist
121
+ go_to :send_invitations
122
+ end
123
+ ```
124
+ Again, instead of using a block in the action handler, you could provide a method to do the work.
125
+
126
+ ```ruby
127
+ action :have_a_party
128
+
129
+ def have_a_party(data)
130
+ data.food = buy_some_food_for(data.number_of_guests)
131
+ data.beer = buy_some_beer_for(data.number_of_guests)
132
+ data.music = plan_a_party_playlist
133
+ go_to :send_invitations
134
+ end
135
+ ```
136
+ Note that when using a method you need to refer to the `data` parameter directly, when using a block, you need to refer to the `task` - see the section on "[Data](#data-and-results)" for more information.
137
+
138
+ Do not forget to call `go_to` from your action handler, otherwise the operation will just stop whilst still being marked as in progress.
139
+
140
+ ### Results
141
+ A result handler marks the end of an operation, optionally returning some results. You need to copy your desired results from your [data](#data-and-results) to your results object. This is so only the information that matters to you is stored in the database (as many operations may have a large set of working data).
142
+
143
+ ```ruby
144
+ action :send_invitations do
145
+ self.invited_friends = (0..number_of_guests).collect do |i|
146
+ friend = friends.pop
147
+ FriendsMailer.with(recipient: friend).party_invitation.deliver_later
148
+ friend
149
+ end
150
+ go_to :ready_to_party
151
+ end
152
+
153
+ result :ready_to_party do |results|
154
+ results.invited_friends = invited_friends
155
+ end
156
+ ```
157
+ After this result handler has executed, the task will then be marked as `completed?`, the task's state will be `ready_to_party` and `results.invited_friends` will contain an array of the people you sent invitations to.
158
+
159
+ If you don't have any meaningful results, you can omit the block on your result handler.
160
+ ```ruby
161
+ result :go_to_work
162
+ ```
163
+ In this case, the task will be marked as `completed?`, the task's state will be `go_to_work` and `results` will be empty.
164
+
165
+ ### Calling an operation
166
+ You would use the earlier [PrepareDocumentForDownload](spec/examples/prepare_document_for_download_spec.rb) operation in a controller like this:
167
+
168
+ ```ruby
169
+ class DownloadsController < ApplicationController
170
+ def show
171
+ @document = Document.includes(:account).find(params[:id])
172
+ @task = PrepareDocumentForDownload.call(user: Current.user, document: @document, use_filename_scrambler: @document.account.use_filename_scrambler?)
173
+ if @task.completed?
174
+ @filename = @task.results.filename
175
+ send_data @document.contents, filename: @filename, disposition: "attachment"
176
+ else
177
+ render action: "error", message: @task.results.failure_message, status: 401
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ OK - so that's a pretty longwinded way of performing a simple task. But, in Collabor8Online, the actual operation for handling downloads has over twenty states, with half of them being decisions (as there are a number of feature flags and per-account configuration options). When you get to complex decision trees like that, being able to lay them out as state transitions becomes invaluable.
184
+
185
+ ### Data and results
186
+ Each operation carries its own, mutable, data for the duration of the operation. This is provided when you `call` the operation to start it and is passed through to each decision, action and result. This data is transient and not stored in the database. If you modify the data then that modification is passed on to the next handler.
187
+
188
+ For example, in the [DownloadsController](#calling-an-operation) shown above, the `user`, `document` and `use_filename_scrambler` are set within the data object when the operation is started. But if the `scramble_filename` action is called, it generates a new filename and adds that to the data object as well. Finally the `return_filename` result handler then returns either the scrambled or the original filename to the caller.
189
+
190
+ Within handlers implemented as blocks, you can read the data directly - for example, `condition { use_filename_scrambler }` from the `use_filename_scrambler?` decision shown earlier. If you want to modify a value, or add a new one, you must use `self` - `self.my_data = "something important"`. This is because the data is carried using a [DataCarrier](/app/models/operations/task/data_carrier.rb) object and `instance_eval` is used within your block handlers. This also means that block handlers must use `task.method` to access methods or data on the task object itself (as you are not actually within the context of the task object itself). The exceptions are the `go_to` and `fail_with` methods which the data carrier forwards to the task.
191
+
192
+ Handlers can alternatively be implemented as methods on the task itself. This means that they are executed within the context of the task and can methods and variables belonging to the task. Each handler method receives a `data` parameter which is the data carrier for that task. Individual items can be accessed as a hash - `data[:my_item]` - or as an attribute - `data.my_item`.
193
+
194
+ The final `results` data from any `result` handlers is stored, along with the task, in the database, so it can be examined later. It is accessed as an OpenStruct that is encoded into JSON. But any ActiveRecord models are translated using a [GlobalID](https://github.com/rails/globalid) using [ActiveJob::Arguments](https://guides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments). Be aware that if you do store an ActiveRecord model into your `results` and that model is later deleted from the database, your task's `results` will be unavailable, as the `GlobalID::Locator` will fail when it tries to load the record. The data is not lost though - if the deserialisation fails, the routine will return the JSON string as `results.raw_data`.
195
+
196
+ ### Failures and exceptions
197
+
198
+ If any handlers raise an exception, the task will be terminated. It will be marked as `failed?` and the `results` hash will contain `results.exception_message`, `results.exception_class` and `results.exception_backtrace` for the exception's message, class name and backtrace respectively.
199
+
200
+ You can also stop a task at any point by calling `fail_with message`. This will mark the task as `failed?` and the `reeults` has will contain `results.failure_message`.
201
+
202
+ ### Task life-cycle and the database
203
+
204
+ There is an ActiveRecord migration that creates the `operations_tasks` table. Use `bin/rails app:operations:install:migrations` to copy it to your application.
205
+
206
+ When you `call` a task, it is written to the database. Then whenever a state transition occurs, the task record is updated.
207
+
208
+ This gives you a number of possibilities:
209
+ - you can access the results (or error state) of a task after it has completed
210
+ - you can use [TurboStream broadcasts](https://turbo.hotwired.dev/handbook/streams) to update your user-interface as the state changes - see "[status messages](#status-messages)" below
211
+ - tasks can run in the background (using ActiveJob) and other parts of your code can interact with them whilst they are in progress - see "[background operations](#background-operations-and-pauses)" below
212
+ - the tasks table acts as an audit trail or activity log for your application
213
+
214
+ However, it also means that your database table could fill up with junk that you're no longer interested in. Therefore you can specify the maximum age of a task and, periodically, clean old tasks away. Every task has a `delete_at` field that, by default, is set to `90.days.from_now`. This can be changed by calling `Operations::Task.delete_after 7.days` (or whatever value you prefer). Then, run a cron job (once per day) that calls `Operations::Task.delete_expired`, removing any tasks whose `deleted_at` date has passed.
215
+
216
+ ### Status messages
217
+
218
+ Documentation coming soon.
219
+
220
+ ### Child tasks
221
+
222
+ Coming soon.
223
+
224
+ ### Background operations and pauses
225
+
226
+ Coming soon.
227
+
228
+ ## Installation
229
+ Add this line to your application's Gemfile:
230
+
231
+ ```ruby
232
+ gem "standard_procedure_operations"
233
+ ```
234
+
235
+ Run `bundle install`, copy and run the migrations to add the tasks table to your database:
236
+
237
+ ```sh
238
+ bin/rails app:operations:install:migrations
239
+ bin/rails db:migrate
240
+ ```
241
+
242
+ Then create your own operations by inheriting from `Operations::Task`.
243
+
244
+ ```ruby
245
+ class DailyLife < Operations::Task
246
+ starts_with :am_i_awake?
247
+
248
+ decision :am_i_awake? do
249
+ if_true :live_like_theres_no_tomorrow
250
+ if_false :rest_and_recuperate
251
+ end
252
+
253
+ result :live_like_theres_no_tomorrow
254
+ result :rest_and_recuperate
255
+
256
+ def am_i_awake? = (7..23).include?(Time.now.hour)
257
+ end
258
+ ```
259
+
260
+ ## License
261
+ The gem is available as open source under the terms of the [LGPL License](/LICENSE). This may or may not make it suitable for your needs.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/test_app/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+ require "rspec/core"
10
+ require "rspec/core/rake_task"
11
+
12
+ desc "Run all specs in spec directory (excluding plugin specs)"
13
+ RSpec::Core::RakeTask.new(spec: "app:db:test:prepare")
14
+
15
+ task default: :spec
@@ -0,0 +1,5 @@
1
+ class Operations::Task::DataCarrier < OpenStruct
2
+ def go_to(state, message = nil) = task.go_to(state, self, message)
3
+
4
+ def fail_with(message) = task.fail_with(message)
5
+ end
@@ -0,0 +1,17 @@
1
+ module Operations::Task::Deletion
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ scope :for_deletion, -> { where(delete_at: ..Time.now.utc) }
6
+ attribute :delete_at, :datetime, default: -> { deletes_after.from_now.utc }
7
+ validates :delete_at, presence: true
8
+ end
9
+
10
+ class_methods do
11
+ def delete_after(value) = @@deletes_after = value
12
+
13
+ def deletes_after = @@deletes_after ||= 90.days
14
+
15
+ def delete_expired = for_deletion.destroy_all
16
+ end
17
+ end
@@ -0,0 +1,80 @@
1
+ module Operations::Task::StateManagement
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ attribute :state, :string
6
+ validate :state_is_valid
7
+ end
8
+
9
+ class_methods do
10
+ def starts_with(value) = @initial_state = value.to_sym
11
+
12
+ def initial_state = @initial_state
13
+
14
+ def decision(name, &config) = state_handlers[name.to_sym] = DecisionHandler.new(name, &config)
15
+
16
+ def action(name, &handler) = state_handlers[name.to_sym] = ActionHandler.new(name, &handler)
17
+
18
+ def result(name, &results) = state_handlers[name.to_sym] = CompletionHandler.new(name, &results)
19
+
20
+ def state_handlers = @state_handlers ||= {}
21
+
22
+ def handler_for(state) = state_handlers[state.to_sym]
23
+ end
24
+
25
+ private def handler_for(state) = self.class.handler_for(state.to_sym)
26
+ private def process_current_state(data)
27
+ handler_for(state).call(self, data)
28
+ rescue => ex
29
+ update! status: "failed", results: OpenStruct.new(exception_message: ex.message, exception_class: ex.class.name, exception_backtrace: ex.backtrace)
30
+ end
31
+ private def state_is_valid
32
+ errors.add :state, :invalid if state.blank? || handler_for(state.to_sym).nil?
33
+ end
34
+
35
+ class ActionHandler
36
+ def initialize name, &action
37
+ @name = name.to_sym
38
+ @action = action
39
+ end
40
+
41
+ def call(task, data)
42
+ @action.nil? ? task.send(@name, data) : data.instance_exec(&@action)
43
+ end
44
+ end
45
+
46
+ class DecisionHandler
47
+ def initialize name, &config
48
+ @name = name.to_sym
49
+ @condition = nil
50
+ @true_state = nil
51
+ @false_state = nil
52
+ instance_eval(&config)
53
+ end
54
+
55
+ def condition(&condition) = @condition = condition
56
+
57
+ def if_true(state = nil, &handler) = @true_state = state || handler
58
+
59
+ def if_false(state = nil, &handler) = @false_state = state || handler
60
+
61
+ def call(task, data)
62
+ result = @condition.nil? ? task.send(@name, data) : data.instance_exec(&@condition)
63
+ next_state = result ? @true_state : @false_state
64
+ next_state.respond_to?(:call) ? data.instance_eval(&next_state) : task.go_to(next_state, data)
65
+ end
66
+ end
67
+
68
+ class CompletionHandler
69
+ def initialize name, &handler
70
+ @name = name.to_sym
71
+ @handler = handler
72
+ end
73
+
74
+ def call(task, data)
75
+ results = OpenStruct.new
76
+ data.instance_exec(results, &@handler) unless @handler.nil?
77
+ task.send :complete, results
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,20 @@
1
+ module Operations
2
+ class Task < ApplicationRecord
3
+ include StateManagement
4
+ include Deletion
5
+ enum :status, in_progress: 0, completed: 1, failed: -1
6
+ composed_of :results, class_name: "OpenStruct", constructor: ->(results) { results.to_h }, converter: ->(hash) { OpenStruct.new(hash) }
7
+ serialize :results, coder: Operations::GlobalIDSerialiser, type: Hash, default: {}
8
+
9
+ def self.call(data = {}) = create!(state: initial_state).tap { |task| task.send(:process_current_state, DataCarrier.new(data.merge(task: task))) }
10
+
11
+ def go_to(state, data = {}, message = nil)
12
+ update!(state: state, status_message: message || state.to_s)
13
+ process_current_state(data)
14
+ end
15
+
16
+ def fail_with(message) = update! status: "failed", results: {failure_message: message.to_s}
17
+
18
+ private def complete(results) = update!(status: "completed", results: results)
19
+ end
20
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end
@@ -0,0 +1,17 @@
1
+ class CreateOperationsTasks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :operations_tasks do |t|
4
+ t.string :type
5
+ t.integer :status, default: 0, null: false
6
+ t.string :state, null: false
7
+ t.string :status_message, default: "", null: false
8
+ t.text :data, default: "{}"
9
+ t.text :results, default: "{}"
10
+ t.boolean :background, default: false, null: false
11
+ t.datetime :delete_at, null: false, index: true
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :operations_tasks, [:type, :status]
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module Operations
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Operations
4
+
5
+ config.generators do |g|
6
+ g.test_framework :rspec
7
+ g.assets false
8
+ g.helper false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ module Operations
2
+ # Serialise and deserialise data to and from JSON
3
+ # Unlike the standard JSON coder, this coder uses the ActiveJob::Arguments serializer.
4
+ # This means that if the data contains an ActiveRecord model, it will be serialised as a GlobalID string
5
+ #
6
+ # Usage:
7
+ # class MyModel < ApplicationRecord
8
+ # serialize :data, coder: GlobalIDSerialiser, type: Hash, default: {}
9
+ # end
10
+ # @my_model = MyModel.create! data: {hello: "world", user: User.first}
11
+ # puts @my_model[:data] # => {hello: "world", user: #<User id: 1>}
12
+ class GlobalIDSerialiser
13
+ def self.dump(data) = ActiveSupport::JSON.dump(ActiveJob::Arguments.serialize([data]))
14
+
15
+ def self.load(json)
16
+ ActiveJob::Arguments.deserialize(ActiveSupport::JSON.decode(json)).first
17
+ rescue => ex
18
+ {exception_message: ex.message, exception_class: ex.class.name, raw_data: json.to_s}
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module Operations
2
+ VERSION = "0.1.0"
3
+ end
data/lib/operations.rb ADDED
@@ -0,0 +1,7 @@
1
+ require "ostruct"
2
+
3
+ module Operations
4
+ require "operations/version"
5
+ require "operations/engine"
6
+ require "operations/global_id_serialiser"
7
+ end
@@ -0,0 +1 @@
1
+ require_relative "operations"
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :operations do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: standard_procedure_operations
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rahoul Baruah
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-01-29 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 7.1.3
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 7.1.3
26
+ description: Pipelines and State Machines for composable, trackable business logic
27
+ email:
28
+ - rahoulb@echodek.co
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - README.md
35
+ - Rakefile
36
+ - app/models/operations/task.rb
37
+ - app/models/operations/task/data_carrier.rb
38
+ - app/models/operations/task/deletion.rb
39
+ - app/models/operations/task/state_management.rb
40
+ - config/routes.rb
41
+ - db/migrate/20250127160616_create_operations_tasks.rb
42
+ - lib/operations.rb
43
+ - lib/operations/engine.rb
44
+ - lib/operations/global_id_serialiser.rb
45
+ - lib/operations/version.rb
46
+ - lib/standard_procedure_operations.rb
47
+ - lib/tasks/operations_tasks.rake
48
+ homepage: https://theartandscienceofruby.com/
49
+ licenses:
50
+ - LGPL
51
+ metadata:
52
+ allowed_push_host: https://rubygems.org
53
+ homepage_uri: https://theartandscienceofruby.com/
54
+ source_code_uri: https://github.com/standard-procedure/operations
55
+ changelog_uri: https://github.com/standard-procedure/operations/releases
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 3.6.2
71
+ specification_version: 4
72
+ summary: Operations
73
+ test_files: []