sequel-state-machine 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/sequel/plugins/state_machine.rb +167 -0
- data/lib/sequel/plugins/state_machine_audit_log.rb +66 -0
- data/lib/state_machines/sequel/spec_helpers.rb +57 -0
- data/lib/state_machines/sequel/version.rb +7 -0
- data/lib/state_machines/sequel.rb +64 -0
- data/lib/state_machines.rb +4 -0
- metadata +164 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 75e3b94c74a1a1c835374f9e6be8d51c84b62d24cc7fd205f00ee325e1dde15a
|
4
|
+
data.tar.gz: 44fcd2ffd47a5ade52a4c9eda0f6bc86f8b5991f8d9e7cbf7cf9fe0194ba8aa1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 38a3c446b7b00f3a80b1257a37a0705712bc93e397963378709b0bc289aa8e85d372d869e53c94ae439a1ab83c3b507cb07af0ebe5049abd89e9c8c76088e382
|
7
|
+
data.tar.gz: 7a7da89ec5c83001e726c90dd83e52b973754528a71d9a360c78ea2a1bb9a09ae9dc47c965e5962d156c38fd85a052c0ac56d7f29607e8689564f8cd11b0aec0
|
@@ -0,0 +1,167 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sequel"
|
4
|
+
require "sequel/model"
|
5
|
+
require "state_machines/sequel"
|
6
|
+
|
7
|
+
module Sequel
|
8
|
+
module Plugins
|
9
|
+
module StateMachine
|
10
|
+
module InstanceMethods
|
11
|
+
def new_audit_log
|
12
|
+
audit_log_assoc = self.class.association_reflections[:audit_logs]
|
13
|
+
model = Kernel.const_get(audit_log_assoc[:class_name])
|
14
|
+
return model.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def current_audit_log
|
18
|
+
if @current_audit_log.nil?
|
19
|
+
StateMachines::Sequel.log(self, :debug, "preparing_audit_log", {})
|
20
|
+
@current_audit_log = self.new_audit_log
|
21
|
+
@current_audit_log.reason = ""
|
22
|
+
end
|
23
|
+
return @current_audit_log
|
24
|
+
end
|
25
|
+
|
26
|
+
def commit_audit_log(transition)
|
27
|
+
StateMachines::Sequel.log(self, :debug, "committing_audit_log", {transition: transition})
|
28
|
+
current = self.current_audit_log
|
29
|
+
|
30
|
+
last_saved = self.audit_logs.find do |a|
|
31
|
+
a.event == transition.event.to_s &&
|
32
|
+
a.from_state == transition.from &&
|
33
|
+
a.to_state == transition.to
|
34
|
+
end
|
35
|
+
if last_saved
|
36
|
+
StateMachines::Sequel.log(self, :debug, "updating_audit_log", {audit_log_id: last_saved.id})
|
37
|
+
last_saved.update(
|
38
|
+
at: Time.now,
|
39
|
+
actor: StateMachines::Sequel.current_actor,
|
40
|
+
messages: current.messages,
|
41
|
+
reason: current.reason,
|
42
|
+
)
|
43
|
+
else
|
44
|
+
StateMachines::Sequel.log(self, :debug, "creating_audit_log", {})
|
45
|
+
current.set(
|
46
|
+
at: Time.now,
|
47
|
+
actor: StateMachines::Sequel.current_actor,
|
48
|
+
event: transition.event.to_s,
|
49
|
+
from_state: transition.from,
|
50
|
+
to_state: transition.to,
|
51
|
+
)
|
52
|
+
self.add_audit_log(current)
|
53
|
+
end
|
54
|
+
@current_audit_log = nil
|
55
|
+
end
|
56
|
+
|
57
|
+
def audit(message, reason: nil)
|
58
|
+
audlog = self.current_audit_log
|
59
|
+
if audlog.class.state_machine_messages_supports_array
|
60
|
+
audlog.messages ||= []
|
61
|
+
audlog.messages << message
|
62
|
+
else
|
63
|
+
audlog.messages ||= ""
|
64
|
+
audlog.messages += (audlog.messages.empty? ? message : (message + "\n"))
|
65
|
+
end
|
66
|
+
audlog.reason = reason if reason
|
67
|
+
end
|
68
|
+
|
69
|
+
def audit_one_off(event, messages, reason: nil)
|
70
|
+
messages = [messages] unless messages.respond_to?(:to_ary)
|
71
|
+
audlog = self.new_audit_log
|
72
|
+
audlog.set(
|
73
|
+
at: Time.now,
|
74
|
+
event: event,
|
75
|
+
from_state: self.status,
|
76
|
+
to_state: self.status,
|
77
|
+
messages: audlog.class.state_machine_messages_supports_array ? messages : messages.join("\n"),
|
78
|
+
reason: reason || "",
|
79
|
+
actor: StateMachines::Sequel.current_actor,
|
80
|
+
)
|
81
|
+
self.add_audit_log(audlog)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Send event with arguments inside of a transaction, save the changes to the receiver,
|
85
|
+
# and return the transition result.
|
86
|
+
# Used to ensure the event processing happens in a transaction and the receiver is saved.
|
87
|
+
def process(event, *args)
|
88
|
+
self.db.transaction do
|
89
|
+
self.lock!
|
90
|
+
result = self.send(event, *args)
|
91
|
+
self.save_changes
|
92
|
+
return result
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Same as process, but raises an error if the transition fails.
|
97
|
+
def must_process(event, *args)
|
98
|
+
success = self.process(event, *args)
|
99
|
+
raise StateMachines::Sequel::FailedTransition.new(self, event) unless success
|
100
|
+
return self
|
101
|
+
end
|
102
|
+
|
103
|
+
# Same as must_process, but takes a lock,
|
104
|
+
# and calls the given block, only doing actual processing if the block returns true.
|
105
|
+
# If the block returns false, it acts as a success.
|
106
|
+
# Used to avoid issues concurrently processing the same object through the same state.
|
107
|
+
def process_if(event, *args)
|
108
|
+
self.db.transaction do
|
109
|
+
self.lock!
|
110
|
+
return self unless yield(self)
|
111
|
+
return self.must_process(event, *args)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# Return true if the given event can be transitioned into by the current state.
|
116
|
+
def valid_state_path_through?(event)
|
117
|
+
current_state = self.send(self._state_value_attr).to_sym
|
118
|
+
event_obj = self.class.state_machine.events[event] or raise "Invalid event #{event}"
|
119
|
+
event_obj.branches.each do |branch|
|
120
|
+
branch.state_requirements.each do |state_req|
|
121
|
+
return true if state_req[:from]&.matches?(current_state)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
return false
|
125
|
+
end
|
126
|
+
|
127
|
+
def _state_value_attr
|
128
|
+
return @_state_value_attr ||= self.class.state_machine.attribute
|
129
|
+
end
|
130
|
+
|
131
|
+
def validates_state_machine
|
132
|
+
states = self.class.state_machine.states.map(&:value)
|
133
|
+
state = self[self._state_value_attr]
|
134
|
+
return if states.include?(state)
|
135
|
+
self.errors.add(self._state_value_attr, "status '#{state}' must be one of (#{states.sort.join(', ')})")
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
module ClassMethods
|
140
|
+
def timestamp_accessors(events_and_accessors)
|
141
|
+
events_and_accessors.each do |(ev, acc)|
|
142
|
+
self.timestamp_accessor(ev, acc)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Register the timestamp access for an event.
|
147
|
+
# A timestamp accessor reads when a certain transition happened
|
148
|
+
# by looking at the timestamp of the successful transition into that state.
|
149
|
+
#
|
150
|
+
# The event can be just the event name, or a hash of {event: <event method symbol>, from: <state name>},
|
151
|
+
# used when a single event can cause multiple transitions.
|
152
|
+
def timestamp_accessor(event, accessor)
|
153
|
+
define_method(accessor) do
|
154
|
+
event = {event: event} if event.is_a?(String)
|
155
|
+
audit = self.audit_logs.select(&:succeeded?).find do |a|
|
156
|
+
ev_match = event[:event].nil? || event[:event] == a.event
|
157
|
+
from_match = event[:from].nil? || event[:from] == a.from_state
|
158
|
+
to_match = event[:to].nil? || event[:to] == a.to_state
|
159
|
+
ev_match && from_match && to_match
|
160
|
+
end
|
161
|
+
return audit&.at
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sequel"
|
4
|
+
require "sequel/model"
|
5
|
+
|
6
|
+
module Sequel
|
7
|
+
module Plugins
|
8
|
+
module StateMachineAuditLog
|
9
|
+
DEFAULT_OPTIONS = {
|
10
|
+
messages_supports_array: :undefined,
|
11
|
+
column_mappings: {
|
12
|
+
at: :at,
|
13
|
+
event: :event,
|
14
|
+
to_state: :to_state,
|
15
|
+
from_state: :from_state,
|
16
|
+
reason: :reason,
|
17
|
+
messages: :messages,
|
18
|
+
actor_id: :actor_id,
|
19
|
+
}.freeze,
|
20
|
+
}.freeze
|
21
|
+
def self.configure(model, opts=DEFAULT_OPTIONS)
|
22
|
+
opts = DEFAULT_OPTIONS.merge(opts)
|
23
|
+
model.state_machine_column_mappings = opts[:column_mappings]
|
24
|
+
msgarray = opts[:messages_supports_array] || true
|
25
|
+
if msgarray == :undefined
|
26
|
+
msgcol = model.state_machine_column_mappings[:messages]
|
27
|
+
dbt = model.db_schema[msgcol][:db_type]
|
28
|
+
msgarray = dbt.include?("json") || dbt.include?("[]")
|
29
|
+
end
|
30
|
+
model.state_machine_messages_supports_array = msgarray
|
31
|
+
end
|
32
|
+
|
33
|
+
module ClassMethods
|
34
|
+
attr_accessor :state_machine_messages_supports_array, :state_machine_column_mappings
|
35
|
+
end
|
36
|
+
|
37
|
+
module DatasetMethods
|
38
|
+
def failed
|
39
|
+
colmap = self.state_machine_column_mappings
|
40
|
+
tostate_col = colmap[:to_state]
|
41
|
+
fromstate_col = colmap[:from_state]
|
42
|
+
return self.where(tostate_col => fromstate_col)
|
43
|
+
end
|
44
|
+
|
45
|
+
def succeeded
|
46
|
+
colmap = self.state_machine_column_mappings
|
47
|
+
tostate_col = colmap[:to_state]
|
48
|
+
fromstate_col = colmap[:from_state]
|
49
|
+
return self.exclude(tostate_col => fromstate_col)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
module InstanceMethods
|
54
|
+
def failed?
|
55
|
+
colmap = self.state_machine_column_mappings
|
56
|
+
return colmap[:from_state] == colmap[:to_state]
|
57
|
+
end
|
58
|
+
|
59
|
+
def succeeded?
|
60
|
+
colmap = self.state_machine_column_mappings
|
61
|
+
return colmap[:from_state] != colmap[:to_state]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rspec"
|
4
|
+
|
5
|
+
RSpec::Matchers.define :transition_on do |event|
|
6
|
+
match do |receiver|
|
7
|
+
raise 'must provide a "to" state' if (@to || "").to_s.empty?
|
8
|
+
receiver.send(event, *@args)
|
9
|
+
@to == receiver.status
|
10
|
+
end
|
11
|
+
|
12
|
+
chain :to do |to_state|
|
13
|
+
@to = to_state
|
14
|
+
end
|
15
|
+
|
16
|
+
chain :with do |*args|
|
17
|
+
@args = args
|
18
|
+
end
|
19
|
+
|
20
|
+
chain :audit do
|
21
|
+
@audit = true
|
22
|
+
end
|
23
|
+
|
24
|
+
failure_message do |receiver|
|
25
|
+
msg =
|
26
|
+
if @to == receiver.status
|
27
|
+
"expected that event #{event} would transition, but did not"
|
28
|
+
else
|
29
|
+
"expected that event #{event} would transition to #{@to} but is #{receiver.status}"
|
30
|
+
end
|
31
|
+
(msg += "\n#{receiver.audit_logs.map(&:inspect).join("\n")}") if @audit
|
32
|
+
msg
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
RSpec::Matchers.define :not_transition_on do |event|
|
37
|
+
match do |receiver|
|
38
|
+
!receiver.send(event, *@args)
|
39
|
+
end
|
40
|
+
|
41
|
+
chain :with do |*args|
|
42
|
+
@args = args
|
43
|
+
end
|
44
|
+
|
45
|
+
failure_message do |receiver|
|
46
|
+
"expected that event #{event} would not transition, but did and is now #{receiver.status}"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
RSpec.shared_examples "a state machine with audit logging" do |event, to_state|
|
51
|
+
it "logs transitions" do
|
52
|
+
expect(machine).to transition_on(event).to(to_state)
|
53
|
+
expect(machine.audit_logs).to contain_exactly(
|
54
|
+
have_attributes(to_state: to_state, event: event.to_s),
|
55
|
+
)
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module StateMachines
|
4
|
+
module Sequel
|
5
|
+
class CurrentActorAlreadySet < StateMachines::Error; end
|
6
|
+
|
7
|
+
class FailedTransition < StateMachines::Error
|
8
|
+
attr_reader :event, :object, :audit_log
|
9
|
+
|
10
|
+
def initialize(obj, event)
|
11
|
+
@event = event
|
12
|
+
@object = obj
|
13
|
+
msg = "#{obj.class}[#{obj.id}] failed to transition on #{event}"
|
14
|
+
if obj.respond_to?(:audit_logs) && obj.audit_logs.present?
|
15
|
+
@audit_log = obj.audit_logs.max_by(&:id)
|
16
|
+
msg += ": #{@audit_log.messages.last}"
|
17
|
+
end
|
18
|
+
super(msg)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class << self
|
23
|
+
attr_accessor :structured_logging
|
24
|
+
|
25
|
+
# Proc called with [instance, level, message, params].
|
26
|
+
# By default, logs to `instance.logger` if it instance responds to :logger.
|
27
|
+
# If structured_logging is true, the message will be an 'event' without any dynamic info,
|
28
|
+
# if false, the params will be rendered into the message so are suitable for unstructured logging.
|
29
|
+
attr_accessor :log_callback
|
30
|
+
|
31
|
+
def reset_logging
|
32
|
+
self.log_callback = lambda { |instance, level, msg, _params|
|
33
|
+
instance.respond_to?(:logger) ? instance.logger.send(level, msg) : nil
|
34
|
+
}
|
35
|
+
self.structured_logging = false
|
36
|
+
end
|
37
|
+
|
38
|
+
def log(instance, level, message, params)
|
39
|
+
if self.structured_logging
|
40
|
+
paramstr = params.map { |k, v| "#{k}=#{v}" }.join(" ")
|
41
|
+
message = "#{message} #{paramstr}"
|
42
|
+
end
|
43
|
+
self.log_callback[instance, level, message, params]
|
44
|
+
end
|
45
|
+
|
46
|
+
def current_actor
|
47
|
+
return Thread.current[:sequel_state_machines_current_actor]
|
48
|
+
end
|
49
|
+
|
50
|
+
def set_current_actor(admin, &block)
|
51
|
+
raise CurrentActorAlreadySet, "already set to: #{self.current_actor}" if !admin.nil? && !self.current_actor.nil?
|
52
|
+
Thread.current[:sequel_state_machines_current_actor] = admin
|
53
|
+
return if block.nil?
|
54
|
+
begin
|
55
|
+
yield
|
56
|
+
ensure
|
57
|
+
Thread.current[:sequel_state_machines_current_actor] = nil
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
StateMachines::Sequel.reset_logging
|
metadata
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sequel-state-machine
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Lithic Tech
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-03-06 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.10'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.10'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec-core
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.10'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.10'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rubocop
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.11'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.11'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop-performance
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.10'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.10'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop-sequel
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0.2'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0.2'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: sequel
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '5.0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '5.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: sqlite3
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '1'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '1'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: state_machines
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: |
|
126
|
+
sequel-state-machine hooks together the excellent Ruby Sequel ORM to
|
127
|
+
the state-machines library, with auditing and other tools.
|
128
|
+
email: hello@lithic.tech
|
129
|
+
executables: []
|
130
|
+
extensions: []
|
131
|
+
extra_rdoc_files: []
|
132
|
+
files:
|
133
|
+
- lib/sequel/plugins/state_machine.rb
|
134
|
+
- lib/sequel/plugins/state_machine_audit_log.rb
|
135
|
+
- lib/state_machines.rb
|
136
|
+
- lib/state_machines/sequel.rb
|
137
|
+
- lib/state_machines/sequel/spec_helpers.rb
|
138
|
+
- lib/state_machines/sequel/version.rb
|
139
|
+
homepage: https://github.com/lithictech/sequel-state-machine
|
140
|
+
licenses:
|
141
|
+
- MIT
|
142
|
+
metadata:
|
143
|
+
rubygems_mfa_required: 'true'
|
144
|
+
post_install_message:
|
145
|
+
rdoc_options: []
|
146
|
+
require_paths:
|
147
|
+
- lib
|
148
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: 2.7.0
|
153
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
154
|
+
requirements:
|
155
|
+
- - ">="
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
requirements: []
|
159
|
+
rubygems_version: 3.1.6
|
160
|
+
signing_key:
|
161
|
+
specification_version: 4
|
162
|
+
summary: Hook together the excellent Ruby Sequel ORM to the state-machines library,
|
163
|
+
with auditing and other tools.
|
164
|
+
test_files: []
|