shikibu 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b3b677128439973fcda2120c7377d1de6cc358bd4cd0b3fd9857e089d85de62
4
- data.tar.gz: 6826b04fff743009f655282d55451c9850066116343c2da1211e7178d451ed6b
3
+ metadata.gz: 3bf06e3a63cc523afe19105e1ed5debd74a949dc3faec06a35ee9d3853d9d4ae
4
+ data.tar.gz: 9877fbfe7dec08f504d067999bd6e83ffd483c050192e7057f29b6d50e2317c1
5
5
  SHA512:
6
- metadata.gz: 3d7c8eb5b7c2722cbb6569659c61fc02540ba5ef9d9d9da55281e9b482e0ff0a567b4434f309010d3faa35aed0ebd6592a97b77826410db3476a34dd80ea4d8b
7
- data.tar.gz: 842f548cc2394a5a4a0aeb1641812c6e26f9e24d3f9f73bfeba6d262e3598429e102b560069897a75a6ea0ea9319c4fa27b67689c00714e5d6be3a5ccf6156e2
6
+ metadata.gz: a9adb5bfbda37f44168434adccde9e22cc65a1c101f1cf2bf2350a003ebd848f2778493b40ade87a4c7e17efa293f949c2c2ac18ce9645bd53fa9c3a42e12a56
7
+ data.tar.gz: 16102137dc003ab1d8b787edd5d78770e84228529f55318d3d0a0b3a7a7cf1e7050acc3826431f48cc96483b30e300df34877b8cbb6891a869b367f4f2a2d88f
data/README.md CHANGED
@@ -198,6 +198,45 @@ end
198
198
 
199
199
  **Activity IDs**: Activities are automatically identified with IDs like `"create_user:1"` for deterministic replay.
200
200
 
201
+ ### Typed Workflows (dry-struct)
202
+
203
+ Shikibu supports typed input/output with [dry-struct](https://dry-rb.org/gems/dry-struct/) for validation and type coercion:
204
+
205
+ ```ruby
206
+ require 'dry-struct'
207
+
208
+ module Types
209
+ include Dry.Types()
210
+ end
211
+
212
+ class OrderInput < Dry::Struct
213
+ attribute :order_id, Types::String
214
+ attribute :amount, Types::Coercible::Decimal
215
+ end
216
+
217
+ class OrderResult < Dry::Struct
218
+ attribute :order_id, Types::String
219
+ attribute :status, Types::String
220
+ end
221
+
222
+ class TypedOrderSaga < Shikibu::Workflow
223
+ workflow_name 'typed_order'
224
+ input OrderInput
225
+ output OrderResult
226
+
227
+ def execute(order)
228
+ # order is an OrderInput instance with validated/coerced types
229
+ OrderResult.new(order_id: order.order_id, status: 'completed')
230
+ end
231
+ end
232
+
233
+ # Run with automatic type coercion
234
+ instance_id = app.start_workflow(TypedOrderSaga, order_id: 'ORD-123', amount: '99.99')
235
+ result = app.get_typed_result(instance_id, TypedOrderSaga) # Returns OrderResult
236
+ ```
237
+
238
+ **Note**: `dry-struct` is an optional dependency. Add it to your Gemfile if you want typed workflows.
239
+
201
240
  ### Durable Execution
202
241
 
203
242
  Shikibu ensures workflow progress is never lost through **deterministic replay**:
data/lib/shikibu/app.rb CHANGED
@@ -162,6 +162,18 @@ module Shikibu
162
162
  }
163
163
  end
164
164
 
165
+ # Get workflow result with typed output deserialization
166
+ # @param instance_id [String] Instance ID
167
+ # @param workflow_class [Class] Workflow class with output type definition
168
+ # @return [Object, nil] Typed output or nil if no output
169
+ # @raise [WorkflowNotFoundError] If instance not found
170
+ def get_typed_result(instance_id, workflow_class)
171
+ result = get_result(instance_id)
172
+ return nil unless result[:output]
173
+
174
+ workflow_class.deserialize_output(result[:output])
175
+ end
176
+
165
177
  # Get workflow status
166
178
  # @param instance_id [String] Instance ID
167
179
  # @return [String] Status
@@ -14,9 +14,12 @@ module Shikibu
14
14
  # Start a new workflow instance
15
15
  # @param workflow_class [Class] Workflow class
16
16
  # @param instance_id [String] Instance ID
17
- # @param input [Hash] Input parameters
17
+ # @param input [Hash, Object] Input parameters (Hash for untyped, typed object for typed workflows)
18
18
  # @return [Object, nil] Workflow result or nil if suspended
19
19
  def start_workflow(workflow_class, instance_id:, **input)
20
+ # Serialize input for storage (handles typed inputs)
21
+ serialized_input = workflow_class.serialize_input(input)
22
+
20
23
  # Save workflow definition
21
24
  storage.save_workflow_definition(
22
25
  workflow_name: workflow_class.workflow_name,
@@ -30,12 +33,12 @@ module Shikibu
30
33
  workflow_name: workflow_class.workflow_name,
31
34
  source_hash: workflow_class.source_hash,
32
35
  owner_service: 'default',
33
- input_data: input,
36
+ input_data: serialized_input,
34
37
  status: Status::RUNNING
35
38
  )
36
39
 
37
40
  # Execute the workflow
38
- execute_workflow(instance_id, workflow_class, input, replaying: false)
41
+ execute_workflow(instance_id, workflow_class, serialized_input, replaying: false)
39
42
  end
40
43
 
41
44
  # Resume a workflow from its current state
@@ -197,12 +200,22 @@ module Shikibu
197
200
  workflow.instance_variable_set(:@pending_compensations, [])
198
201
  workflow.context = ctx
199
202
 
200
- # Symbolize input keys
201
- symbolized_input = symbolize_keys(input)
202
- result = workflow.execute(**symbolized_input)
203
+ # Handle typed vs untyped input
204
+ result = if workflow_class.typed_input?
205
+ # Typed workflow: deserialize and pass single object
206
+ typed_input = workflow_class.deserialize_input(input)
207
+ workflow.execute(typed_input)
208
+ else
209
+ # Legacy: symbolize and spread as keyword args
210
+ symbolized_input = symbolize_keys(input)
211
+ workflow.execute(**symbolized_input)
212
+ end
213
+
214
+ # Serialize output
215
+ serialized_output = workflow_class.serialize_output(result)
203
216
 
204
217
  # Mark completed
205
- storage.update_instance_status(instance_id, Status::COMPLETED, output_data: result)
218
+ storage.update_instance_status(instance_id, Status::COMPLETED, output_data: serialized_output)
206
219
  storage.clear_compensations(instance_id)
207
220
 
208
221
  # Cleanup direct subscriptions
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shikibu
4
+ # Module to detect and handle typed payload classes
5
+ # Supports dry-struct, Ruby 3.2+ Data classes, and duck-typed objects
6
+ #
7
+ # @example with dry-struct
8
+ # require 'dry-struct'
9
+ # class OrderInput < Dry::Struct
10
+ # attribute :order_id, Types::String
11
+ # attribute :amount, Types::Coercible::Decimal
12
+ # end
13
+ #
14
+ # TypedPayload.typed_class?(OrderInput) # => true
15
+ # TypedPayload.to_h(OrderInput.new(order_id: '123', amount: 99.99))
16
+ # # => { order_id: '123', amount: 99.99 }
17
+ #
18
+ # @example with Data class
19
+ # OrderInput = Data.define(:order_id, :amount)
20
+ # TypedPayload.typed_class?(OrderInput) # => true
21
+ #
22
+ module TypedPayload
23
+ class << self
24
+ # Check if dry-struct is available
25
+ # @return [Boolean]
26
+ def dry_struct_available?
27
+ return @dry_struct_available if defined?(@dry_struct_available)
28
+
29
+ @dry_struct_available = begin
30
+ require 'dry-struct'
31
+ true
32
+ rescue LoadError
33
+ false
34
+ end
35
+ end
36
+
37
+ # Check if an object is a typed payload class (not instance)
38
+ # @param obj [Object] Object to check
39
+ # @return [Boolean]
40
+ def typed_class?(obj)
41
+ return false unless obj.is_a?(Class)
42
+
43
+ # Check for dry-struct
44
+ return true if dry_struct_class?(obj)
45
+
46
+ # Check for Ruby 3.2+ Data class
47
+ return true if data_class?(obj)
48
+
49
+ # Check for duck-typed class with required methods
50
+ duck_typed_class?(obj)
51
+ end
52
+
53
+ # Check if an object is a typed payload instance
54
+ # @param obj [Object] Object to check
55
+ # @return [Boolean]
56
+ def typed_instance?(obj)
57
+ return true if dry_struct_instance?(obj)
58
+ return true if data_instance?(obj)
59
+
60
+ duck_typed_instance?(obj)
61
+ end
62
+
63
+ # Serialize a typed instance to a hash
64
+ # @param obj [Object] Typed instance or any object
65
+ # @return [Hash, Object] Serialized hash or original object
66
+ def to_h(obj)
67
+ deep_serialize(obj)
68
+ end
69
+
70
+ # Deserialize a hash to a typed instance
71
+ # @param hash [Hash] Hash to deserialize
72
+ # @param type_class [Class] Target type class
73
+ # @return [Object] Typed instance or original hash
74
+ def from_h(hash, type_class)
75
+ return hash unless typed_class?(type_class)
76
+ return hash unless hash.is_a?(Hash)
77
+
78
+ # Symbolize keys for compatibility
79
+ symbolized = deep_symbolize_keys(hash)
80
+
81
+ # All typed classes support .new(**hash) interface
82
+ type_class.new(**symbolized)
83
+ end
84
+
85
+ # Deserialize an array of typed objects
86
+ # @param array [Array<Hash>] Array of hashes
87
+ # @param element_type [Class] Element type class
88
+ # @return [Array] Array of typed instances
89
+ def from_array(array, element_type)
90
+ return array unless array.is_a?(Array)
91
+ return array unless typed_class?(element_type)
92
+
93
+ array.map do |item|
94
+ item.is_a?(Hash) ? from_h(item, element_type) : item
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def dry_struct_class?(obj)
101
+ return false unless dry_struct_available?
102
+
103
+ obj.is_a?(Class) && obj < Dry::Struct
104
+ end
105
+
106
+ def dry_struct_instance?(obj)
107
+ return false unless dry_struct_available?
108
+
109
+ obj.is_a?(Dry::Struct)
110
+ end
111
+
112
+ def data_class?(obj)
113
+ return false unless defined?(Data)
114
+
115
+ obj.is_a?(Class) && obj < Data
116
+ end
117
+
118
+ def data_instance?(obj)
119
+ return false unless defined?(Data)
120
+
121
+ obj.is_a?(Data)
122
+ end
123
+
124
+ def duck_typed_class?(obj)
125
+ # A class is considered typed if it has new and instances have to_h
126
+ # but exclude Hash itself
127
+ return false if obj == Hash
128
+
129
+ obj.respond_to?(:new) &&
130
+ obj.method_defined?(:to_h)
131
+ end
132
+
133
+ def duck_typed_instance?(obj)
134
+ # An instance is typed if it has to_h but is not a Hash/Array
135
+ return false if obj.is_a?(Hash)
136
+ return false if obj.is_a?(Array)
137
+ return false unless obj.respond_to?(:to_h)
138
+
139
+ # Additional check: should have proper to_h implementation
140
+ # (not just Object#to_h which doesn't exist or returns weird things)
141
+ begin
142
+ result = obj.to_h
143
+ result.is_a?(Hash)
144
+ rescue StandardError
145
+ false
146
+ end
147
+ end
148
+
149
+ # Recursively serialize typed objects to hashes
150
+ # Note: Symbols are preserved here. They are converted to strings
151
+ # only during JSON serialization in the storage layer.
152
+ def deep_serialize(obj)
153
+ case obj
154
+ when Hash
155
+ obj.transform_values { |v| deep_serialize(v) }
156
+ when Array
157
+ obj.map { |v| deep_serialize(v) }
158
+ when ->(o) { dry_struct_instance?(o) || data_instance?(o) }
159
+ deep_serialize(obj.to_h)
160
+ when Time, DateTime
161
+ obj.iso8601
162
+ when Date
163
+ obj.to_s
164
+ when BigDecimal
165
+ obj.to_s('F')
166
+ else
167
+ # Symbols, Integers, Strings, etc. are preserved as-is
168
+ obj
169
+ end
170
+ end
171
+
172
+ # Recursively symbolize hash keys
173
+ def deep_symbolize_keys(hash)
174
+ return hash unless hash.is_a?(Hash)
175
+
176
+ symbolized = hash.transform_keys do |key|
177
+ key.is_a?(String) ? key.to_sym : key
178
+ end
179
+ symbolized.transform_values do |value|
180
+ case value
181
+ when Hash then deep_symbolize_keys(value)
182
+ when Array then value.map { |v| v.is_a?(Hash) ? deep_symbolize_keys(v) : v }
183
+ else value
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shikibu
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -59,6 +59,85 @@ module Shikibu
59
59
  end
60
60
  end
61
61
 
62
+ # Set the input type for this workflow
63
+ # @param type_class [Class, nil] A Dry::Struct, Data, or duck-typed class
64
+ # @example
65
+ # class OrderSaga < Shikibu::Workflow
66
+ # input OrderInput
67
+ # output OrderResult
68
+ # end
69
+ def input(type_class = nil)
70
+ if type_class
71
+ unless TypedPayload.typed_class?(type_class)
72
+ raise ArgumentError, "#{type_class} must be a Dry::Struct, Data, or respond to .new and #to_h"
73
+ end
74
+
75
+ @input_type = type_class
76
+ else
77
+ @input_type
78
+ end
79
+ end
80
+
81
+ # Set the output type for this workflow (optional)
82
+ # @param type_class [Class, nil] A Dry::Struct, Data, or duck-typed class
83
+ def output(type_class = nil)
84
+ if type_class
85
+ unless TypedPayload.typed_class?(type_class)
86
+ raise ArgumentError, "#{type_class} must be a Dry::Struct, Data, or respond to .new and #to_h"
87
+ end
88
+
89
+ @output_type = type_class
90
+ else
91
+ @output_type
92
+ end
93
+ end
94
+
95
+ # Check if this workflow has typed input
96
+ # @return [Boolean]
97
+ def typed_input?
98
+ !@input_type.nil?
99
+ end
100
+
101
+ # Check if this workflow has typed output
102
+ # @return [Boolean]
103
+ def typed_output?
104
+ !@output_type.nil?
105
+ end
106
+
107
+ # Serialize input for storage
108
+ # @param input_value [Object] Input to serialize
109
+ # @return [Hash] Serialized hash
110
+ def serialize_input(input_value)
111
+ TypedPayload.to_h(input_value)
112
+ end
113
+
114
+ # Deserialize input from storage
115
+ # @param data [Hash] Stored data
116
+ # @return [Object] Typed input or hash
117
+ def deserialize_input(data)
118
+ return data unless typed_input?
119
+ return data unless data.is_a?(Hash)
120
+
121
+ TypedPayload.from_h(data, @input_type)
122
+ end
123
+
124
+ # Serialize output for storage
125
+ # @param output_value [Object] Output to serialize
126
+ # @return [Hash, Object] Serialized output
127
+ def serialize_output(output_value)
128
+ TypedPayload.to_h(output_value)
129
+ end
130
+
131
+ # Deserialize output from storage
132
+ # @param data [Object] Stored data
133
+ # @return [Object] Typed output or original data
134
+ def deserialize_output(data)
135
+ return data unless typed_output?
136
+ return data unless data.is_a?(Hash)
137
+
138
+ TypedPayload.from_h(data, @output_type)
139
+ end
140
+
62
141
  # Get the source hash for this workflow
63
142
  def source_hash
64
143
  @source_hash ||= begin
@@ -130,9 +209,15 @@ module Shikibu
130
209
  # Execute an activity with automatic retry and history tracking
131
210
  # @param name [Symbol, String] Activity name
132
211
  # @param retry_policy [RetryPolicy] Retry policy
212
+ # @param returns [Class, nil] Return type class for type restoration during replay
133
213
  # @param block [Proc] Activity logic
134
214
  # @return [Object] Activity result
135
- def activity(name, retry_policy: nil, &)
215
+ # @raise [ArgumentError] If returns is not a valid typed class
216
+ def activity(name, retry_policy: nil, returns: nil, &)
217
+ if returns && !TypedPayload.typed_class?(returns)
218
+ raise ArgumentError, "returns: must be a typed class (Dry::Struct, Data, or duck-typed), got #{returns.inspect}"
219
+ end
220
+
136
221
  activity_id = ctx.generate_activity_id(name.to_s)
137
222
  ctx.current_activity_id = activity_id
138
223
 
@@ -141,7 +226,17 @@ module Shikibu
141
226
  cached = ctx.get_cached_result(activity_id)
142
227
  handle_cached_result(activity_id, cached)
143
228
  ctx.record_last_activity_id(activity_id)
144
- return cached[:result] if cached[:event_type] == EventType::ACTIVITY_COMPLETED
229
+
230
+ if cached[:event_type] == EventType::ACTIVITY_COMPLETED
231
+ result = cached[:result]
232
+
233
+ # Restore type if specified
234
+ if returns && TypedPayload.typed_class?(returns) && result.is_a?(Hash)
235
+ result = TypedPayload.from_h(result, returns)
236
+ end
237
+
238
+ return result
239
+ end
145
240
 
146
241
  # Re-raise cached error
147
242
  raise reconstruct_error(cached)
data/lib/shikibu.rb CHANGED
@@ -132,6 +132,7 @@ require_relative 'shikibu/notify/wake_event'
132
132
  require_relative 'shikibu/locking'
133
133
  require_relative 'shikibu/channels'
134
134
  require_relative 'shikibu/context'
135
+ require_relative 'shikibu/typed_payload'
135
136
  require_relative 'shikibu/workflow'
136
137
  require_relative 'shikibu/activity'
137
138
  require_relative 'shikibu/storage/migrations'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shikibu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yasushi Itoh
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-21 00:00:00.000000000 Z
11
+ date: 2025-12-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '2.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: dry-struct
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.6'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.6'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: mysql2
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -192,6 +206,7 @@ files:
192
206
  - lib/shikibu/retry_policy.rb
193
207
  - lib/shikibu/storage/migrations.rb
194
208
  - lib/shikibu/storage/sequel_storage.rb
209
+ - lib/shikibu/typed_payload.rb
195
210
  - lib/shikibu/version.rb
196
211
  - lib/shikibu/worker.rb
197
212
  - lib/shikibu/workflow.rb