dexkit 0.10.0 → 0.11.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.
data/lib/dex/id.rb CHANGED
@@ -5,13 +5,53 @@ require "securerandom"
5
5
  module Dex
6
6
  module Id
7
7
  ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
8
+ BASE = ALPHABET.length
8
9
  TIMESTAMP_WIDTH = 8
9
- RANDOM_WIDTH = 12
10
+ DEFAULT_RANDOM_WIDTH = 12
11
+ MIN_RANDOM_WIDTH = 8
12
+ PREFIX_PATTERN = /\A[a-z][a-z0-9_]*_\z/
13
+ MIN_PAYLOAD_LENGTH = 9 # 8 timestamp + at least 1 random
14
+
15
+ ALPHABET_INDEX = ALPHABET.each_char.with_index.to_h.freeze
16
+
17
+ Parsed = Data.define(:prefix, :created_at, :random)
10
18
 
11
19
  module_function
12
20
 
13
- def generate(prefix)
14
- "#{prefix}#{base58_encode(current_milliseconds, TIMESTAMP_WIDTH)}#{random_suffix(RANDOM_WIDTH)}"
21
+ def generate(prefix, random: DEFAULT_RANDOM_WIDTH)
22
+ validate_prefix!(prefix)
23
+ validate_random_width!(random)
24
+
25
+ "#{prefix}#{base58_encode(current_milliseconds, TIMESTAMP_WIDTH)}#{random_suffix(random)}"
26
+ end
27
+
28
+ def parse(id)
29
+ id = String(id)
30
+ last_underscore = id.rindex("_")
31
+
32
+ unless last_underscore
33
+ raise ArgumentError,
34
+ "Cannot parse #{id.inspect}: no underscore found. Dex::Id strings have the format \"prefix_<timestamp><random>\"."
35
+ end
36
+
37
+ prefix = id[0..last_underscore]
38
+ payload = id[(last_underscore + 1)..]
39
+
40
+ if payload.length < MIN_PAYLOAD_LENGTH
41
+ raise ArgumentError,
42
+ "Cannot parse #{id.inspect}: payload after prefix is #{payload.length} characters, " \
43
+ "need at least #{MIN_PAYLOAD_LENGTH} (#{TIMESTAMP_WIDTH} timestamp + 1 random)."
44
+ end
45
+
46
+ validate_base58!(payload, id)
47
+
48
+ timestamp_chars = payload[0, TIMESTAMP_WIDTH]
49
+ random_chars = payload[TIMESTAMP_WIDTH..]
50
+
51
+ ms = base58_decode(timestamp_chars)
52
+ created_at = Time.at(ms / 1000, ms % 1000 * 1000, :usec).utc
53
+
54
+ Parsed.new(prefix: prefix, created_at: created_at, random: random_chars)
15
55
  end
16
56
 
17
57
  def base58_encode(number, width = nil)
@@ -19,7 +59,7 @@ module Dex
19
59
  value = number.to_i
20
60
 
21
61
  loop do
22
- value, remainder = value.divmod(ALPHABET.length)
62
+ value, remainder = value.divmod(BASE)
23
63
  encoded.prepend(ALPHABET[remainder])
24
64
  break unless value.positive?
25
65
  end
@@ -27,12 +67,59 @@ module Dex
27
67
  width ? encoded.rjust(width, ALPHABET[0]) : encoded
28
68
  end
29
69
 
70
+ def base58_decode(string)
71
+ raise ArgumentError, "expected a String, got #{string.inspect}" unless string.is_a?(String)
72
+
73
+ value = 0
74
+ string.each_char do |char|
75
+ index = ALPHABET_INDEX[char]
76
+ raise ArgumentError, "invalid base58 character #{char.inspect} in #{string.inspect}" unless index
77
+
78
+ value = value * BASE + index
79
+ end
80
+ value
81
+ end
82
+
30
83
  def random_suffix(width)
31
- Array.new(width) { ALPHABET[SecureRandom.random_number(ALPHABET.length)] }.join
84
+ Array.new(width) { ALPHABET[SecureRandom.random_number(BASE)] }.join
32
85
  end
33
86
 
34
87
  def current_milliseconds
35
88
  Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
36
89
  end
90
+
91
+ def validate_prefix!(prefix)
92
+ raise ArgumentError, "prefix must be a non-empty String" unless prefix.is_a?(String) && !prefix.empty?
93
+
94
+ return if PREFIX_PATTERN.match?(prefix)
95
+
96
+ unless prefix.end_with?("_")
97
+ raise ArgumentError,
98
+ "Invalid prefix #{prefix.inspect}: prefix must end with underscore. Did you mean #{"#{prefix}_".inspect}?"
99
+ end
100
+
101
+ raise ArgumentError,
102
+ "Invalid prefix #{prefix.inspect}: prefix must match #{PREFIX_PATTERN.inspect} " \
103
+ "(lowercase alphanumeric with internal underscores, ending in underscore)."
104
+ end
105
+ private_class_method :validate_prefix!
106
+
107
+ def validate_random_width!(width)
108
+ unless width.is_a?(Integer) && width >= MIN_RANDOM_WIDTH
109
+ raise ArgumentError,
110
+ "random: must be an Integer >= #{MIN_RANDOM_WIDTH}, got #{width.inspect}."
111
+ end
112
+ end
113
+ private_class_method :validate_random_width!
114
+
115
+ def validate_base58!(payload, original_id)
116
+ payload.each_char do |char|
117
+ next if ALPHABET_INDEX.key?(char)
118
+
119
+ raise ArgumentError,
120
+ "Cannot parse #{original_id.inspect}: invalid base58 character #{char.inspect} in payload."
121
+ end
122
+ end
123
+ private_class_method :validate_base58!
37
124
  end
38
125
  end
@@ -17,6 +17,12 @@ module Dex
17
17
  end
18
18
  end
19
19
 
20
+ def safe(*)
21
+ raise NoMethodError,
22
+ "safe and async are alternative execution strategies. " \
23
+ "For async outcome reconstruction, use wait/wait! on the ticket."
24
+ end
25
+
20
26
  private
21
27
 
22
28
  def enqueue_direct_job
@@ -27,7 +33,8 @@ module Dex
27
33
  trace: Dex::Trace.dump
28
34
  }
29
35
  apply_once_payload!(payload)
30
- job.perform_later(**payload)
36
+ job = job.perform_later(**payload)
37
+ Operation::Ticket.new(record: nil, job: job)
31
38
  end
32
39
 
33
40
  def enqueue_record_job
@@ -47,7 +54,8 @@ module Dex
47
54
  trace: Dex::Trace.dump
48
55
  }
49
56
  apply_once_payload!(payload)
50
- job.perform_later(**payload)
57
+ job = job.perform_later(**payload)
58
+ Operation::Ticket.new(record: record, job: job)
51
59
  rescue => e
52
60
  begin
53
61
  record.destroy
@@ -124,7 +124,7 @@ module Dex
124
124
  threat = catch(:_dex_halt) { instance_exec(&guard.block) }
125
125
  if threat.is_a?(Operation::Halt)
126
126
  raise ArgumentError,
127
- "guard :#{guard.name} must return truthy/falsy, not call error!/success!/assert!"
127
+ "guard :#{guard.name} must return truthy/falsy, not call error!/success!"
128
128
  end
129
129
 
130
130
  if threat
@@ -26,6 +26,10 @@ module Dex
26
26
  @value.respond_to?(method, include_private) || super
27
27
  end
28
28
 
29
+ def deconstruct
30
+ [@value]
31
+ end
32
+
29
33
  def deconstruct_keys(keys)
30
34
  return { value: @value } unless @value.respond_to?(:deconstruct_keys)
31
35
  @value.deconstruct_keys(keys)
@@ -49,6 +53,10 @@ module Dex
49
53
  def message = @error.message
50
54
  def details = @error.details
51
55
 
56
+ def deconstruct
57
+ [@error]
58
+ end
59
+
52
60
  def deconstruct_keys(keys)
53
61
  { code: @error.code, message: @error.message, details: @error.details }
54
62
  end
@@ -65,6 +73,12 @@ module Dex
65
73
  rescue Dex::Error => e
66
74
  Operation::Err.new(e)
67
75
  end
76
+
77
+ def async(*)
78
+ raise NoMethodError,
79
+ "safe and async are alternative execution strategies. " \
80
+ "For async outcome reconstruction, use wait/wait! on the ticket."
81
+ end
68
82
  end
69
83
  end
70
84
  end
@@ -57,18 +57,6 @@ module Dex
57
57
  throw(:_dex_halt, Operation::Halt.new(type: :success, value: attrs.empty? ? value : attrs))
58
58
  end
59
59
 
60
- def assert!(*args, &block)
61
- if block
62
- code = args[0]
63
- value = yield
64
- else
65
- value, code = args
66
- end
67
-
68
- error!(code) unless value
69
- value
70
- end
71
-
72
60
  private
73
61
 
74
62
  def _result_validate_success_type!(value)
@@ -52,39 +52,6 @@ module Dex
52
52
  result
53
53
  end
54
54
 
55
- # --- One-liner assertions ---
56
-
57
- def assert_operation(*args, returns: :_not_given, **params)
58
- klass = _dex_resolve_subject(args)
59
- result = klass.new(**params).safe.call
60
- assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
61
- if returns != :_not_given
62
- assert_equal returns, result.value, "Return value mismatch"
63
- end
64
- result
65
- end
66
-
67
- def assert_operation_error(*args, message: nil, details: nil, **params)
68
- klass, code = _dex_resolve_subject_and_code(args)
69
- result = klass.new(**params).safe.call
70
- assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
71
- if code
72
- assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
73
- end
74
- if message
75
- case message
76
- when Regexp
77
- assert_match message, result.message
78
- else
79
- assert_equal message, result.message
80
- end
81
- end
82
- details&.each do |key, val|
83
- assert_equal val, result.details&.dig(key)
84
- end
85
- result
86
- end
87
-
88
55
  # --- Contract assertions ---
89
56
 
90
57
  def assert_params(*args)
@@ -199,30 +166,6 @@ module Dex
199
166
  "Expected no operations to be enqueued, but #{after_count - before_count} were"
200
167
  end
201
168
 
202
- # --- Trace assertions ---
203
-
204
- def assert_trace_includes(operation_class, msg: nil)
205
- expected = operation_class.is_a?(Class) ? operation_class.name : operation_class.to_s
206
- trace_classes = Dex::Trace.current.map { |frame| frame[:class] }.compact
207
-
208
- assert_includes trace_classes, expected,
209
- msg || "Expected trace to include #{expected.inspect}, got #{trace_classes.inspect}"
210
- end
211
-
212
- def assert_trace_actor(type:, id: :_not_given, msg: nil)
213
- actor = Dex::Trace.actor
214
- refute_nil actor, msg || "Expected a trace actor, but no actor is set"
215
- assert_equal type.to_s, actor[:actor_type], msg || "Trace actor type mismatch"
216
- assert_equal id.to_s, actor[:id], msg || "Trace actor id mismatch" unless id == :_not_given
217
- actor
218
- end
219
-
220
- def assert_trace_depth(expected, msg: nil)
221
- actual = Dex::Trace.current.size
222
- assert_equal expected, actual,
223
- msg || "Expected trace depth #{expected}, got #{actual}.\nTrace: #{Dex::Trace.current.inspect}"
224
- end
225
-
226
169
  # --- Transaction assertions ---
227
170
 
228
171
  def assert_rolls_back(model_class, &block)
@@ -262,49 +205,6 @@ module Dex
262
205
  result
263
206
  end
264
207
 
265
- # --- Batch assertions ---
266
-
267
- def assert_all_succeed(*args, params_list:)
268
- klass = _dex_resolve_subject(args)
269
- results = params_list.map { |p| klass.new(**p).safe.call }
270
- failures = results.each_with_index.reject { |r, _| r.ok? }
271
- if failures.any?
272
- msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
273
- flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
274
- end
275
- results
276
- end
277
-
278
- def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
279
- klass = _dex_resolve_subject(args)
280
- results = params_list.map { |p| klass.new(**p).safe.call }
281
- failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
282
- if failures.any?
283
- msgs = failures.map { |r, i|
284
- status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
285
- " [#{i}] #{params_list[i].inspect} => #{status}"
286
- }
287
- flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
288
- end
289
- results.each_with_index do |r, i|
290
- if message
291
- case message
292
- when Regexp
293
- assert_match message, r.message,
294
- "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
295
- else
296
- assert_equal message, r.message,
297
- "Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
298
- end
299
- end
300
- details&.each do |key, val|
301
- assert_equal val, r.details&.dig(key),
302
- "Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
303
- end
304
- end
305
- results
306
- end
307
-
308
208
  private
309
209
 
310
210
  def _dex_format_err(result)
@@ -322,18 +222,6 @@ module Dex
322
222
  " value: #{result.value.inspect}"
323
223
  end
324
224
 
325
- def _dex_resolve_subject_and_code(args)
326
- if args.first.is_a?(Class) && args.first < Dex::Operation
327
- klass = args.shift
328
- code = args.shift
329
- [klass, code]
330
- elsif args.first.is_a?(Symbol)
331
- [_dex_resolve_subject([]), args.shift]
332
- else
333
- [_dex_resolve_subject([]), nil]
334
- end
335
- end
336
-
337
225
  def _dex_split_class_and_symbols(args)
338
226
  if args.first.is_a?(Class) && args.first < Dex::Operation
339
227
  [args[0..0], args[1..]]
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ class Ticket
6
+ WAIT_MAX_RECOMMENDED = 10
7
+
8
+ attr_reader :record, :job
9
+
10
+ def initialize(record:, job:)
11
+ @record = record
12
+ @job = job
13
+ end
14
+
15
+ def self.from_record(record)
16
+ raise ArgumentError, "from_record requires a record, got nil" unless record
17
+
18
+ new(record: record, job: nil)
19
+ end
20
+
21
+ # --- Delegated accessors ---
22
+
23
+ def id
24
+ _require_record!("id")
25
+ record.id
26
+ end
27
+
28
+ def operation_name
29
+ _require_record!("operation_name")
30
+ record.name
31
+ end
32
+
33
+ def status
34
+ _require_record!("status")
35
+ record.status
36
+ end
37
+
38
+ def error_code
39
+ _require_record!("error_code")
40
+ record.error_code
41
+ end
42
+
43
+ def error_message
44
+ _require_record!("error_message")
45
+ record.error_message
46
+ end
47
+
48
+ def error_details
49
+ _require_record!("error_details")
50
+ record.error_details
51
+ end
52
+
53
+ # --- Predicates ---
54
+
55
+ def completed?
56
+ status == "completed"
57
+ end
58
+
59
+ def error?
60
+ status == "error"
61
+ end
62
+
63
+ def failed?
64
+ status == "failed"
65
+ end
66
+
67
+ def pending?
68
+ status == "pending"
69
+ end
70
+
71
+ def running?
72
+ status == "running"
73
+ end
74
+
75
+ def terminal?
76
+ completed? || error? || failed?
77
+ end
78
+
79
+ def recorded?
80
+ !record.nil?
81
+ end
82
+
83
+ # --- Reload ---
84
+
85
+ def reload
86
+ _require_record!("reload")
87
+ @record = Dex.record_backend.find_record(record.id)
88
+ self
89
+ end
90
+
91
+ # --- Outcome reconstruction ---
92
+
93
+ def outcome
94
+ _require_record!("outcome")
95
+
96
+ case status
97
+ when "completed"
98
+ value = _unwrap_result(record.result)
99
+ value = _coerce_typed_result(value)
100
+ Ok.new(_symbolize_keys(value))
101
+ when "error"
102
+ code = record.error_code&.to_sym
103
+ message = record.error_message
104
+ details = record.error_details
105
+ details = _symbolize_keys(details) if details.is_a?(Hash)
106
+ Err.new(Dex::Error.new(code, message, details: details))
107
+ end
108
+ end
109
+
110
+ # --- Wait ---
111
+
112
+ def wait(timeout, interval: 0.2)
113
+ _validate_wait_args!(timeout, interval)
114
+
115
+ if timeout.to_f > WAIT_MAX_RECOMMENDED
116
+ Dex.warn(
117
+ "Ticket#wait called with #{timeout}s timeout. " \
118
+ "Speculative sync is designed for short waits (under #{WAIT_MAX_RECOMMENDED}s). " \
119
+ "Consider client-side polling for long-running operations."
120
+ )
121
+ end
122
+
123
+ interval_fn = interval.respond_to?(:call) ? interval : ->(_) { interval }
124
+ deadline = _monotonic_now + timeout.to_f
125
+ attempt = 0
126
+
127
+ loop do
128
+ if terminal?
129
+ result = outcome
130
+ raise _build_operation_failed unless result
131
+ return result
132
+ end
133
+
134
+ remaining = deadline - _monotonic_now
135
+ return nil if remaining <= 0
136
+
137
+ pause = [interval_fn.call(attempt).to_f, 0.01].max
138
+ sleep [pause, remaining].min
139
+ attempt += 1
140
+ reload
141
+ end
142
+ end
143
+
144
+ def wait!(timeout, **opts)
145
+ result = wait(timeout, **opts)
146
+ raise Dex::Timeout.new(timeout: timeout, ticket_id: id, operation_name: operation_name) unless result
147
+ return result.value if result.ok?
148
+
149
+ raise result.error
150
+ end
151
+
152
+ # --- to_param ---
153
+
154
+ def to_param
155
+ id.to_s
156
+ end
157
+
158
+ # --- as_json ---
159
+
160
+ def as_json(*)
161
+ _require_record!("as_json")
162
+
163
+ data = { "id" => id.to_s, "name" => operation_name, "status" => status }
164
+
165
+ case status
166
+ when "completed"
167
+ result = _unwrap_result(record.result)
168
+ data["result"] = result unless result.nil?
169
+ when "error"
170
+ data["error"] = {
171
+ "code" => record.error_code,
172
+ "message" => record.error_message,
173
+ "details" => record.error_details
174
+ }.compact
175
+ end
176
+
177
+ data
178
+ end
179
+
180
+ # --- inspect ---
181
+
182
+ def inspect
183
+ if record
184
+ "#<Dex::Operation::Ticket #{operation_name} id=#{id.inspect} status=#{status.inspect}>"
185
+ else
186
+ "#<Dex::Operation::Ticket (unrecorded) job=#{job&.class&.name}>"
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def _require_record!(method)
193
+ return if record
194
+
195
+ raise ArgumentError,
196
+ "#{method} requires a recorded operation. " \
197
+ "Enable recording with `record true` in your operation, or use `Ticket.from_record` with an existing record."
198
+ end
199
+
200
+ def _validate_wait_args!(timeout, interval)
201
+ unless record
202
+ raise ArgumentError,
203
+ "wait requires a recorded operation. Possible causes: " \
204
+ "(1) recording is not enabled — add `record true` to your operation; " \
205
+ "(2) `record params: false` is set — async operations store params in the record to reconstruct " \
206
+ "the operation in the background job, so params: false forces the direct (non-recorded) strategy. " \
207
+ "If you need to avoid storing params (e.g., PII), consider encrypting params at the model level instead."
208
+ end
209
+ unless _valid_duration?(timeout) && timeout.to_f > 0
210
+ raise ArgumentError, "timeout must be a positive Numeric, got: #{timeout.inspect}"
211
+ end
212
+ if !interval.respond_to?(:call) && !(_valid_duration?(interval) && interval.to_f > 0)
213
+ raise ArgumentError, "interval must be a positive number or a callable, got: #{interval.inspect}"
214
+ end
215
+ end
216
+
217
+ def _build_operation_failed
218
+ Dex::OperationFailed.new(
219
+ operation_name: record.name || "Unknown",
220
+ exception_class: record.error_code || "Unknown",
221
+ exception_message: record.error_message || "(no message recorded)"
222
+ )
223
+ end
224
+
225
+ def _valid_duration?(value)
226
+ return true if value.is_a?(Numeric)
227
+ return true if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
228
+
229
+ false
230
+ end
231
+
232
+ def _coerce_typed_result(value)
233
+ return value if value.nil?
234
+
235
+ klass = record.name&.safe_constantize
236
+ return value unless klass
237
+
238
+ success_type = klass.respond_to?(:_success_type) && klass._success_type
239
+ return value unless success_type
240
+
241
+ klass.send(:_coerce_value, success_type, value)
242
+ rescue
243
+ value
244
+ end
245
+
246
+ def _unwrap_result(result)
247
+ return result unless result.is_a?(Hash) && result.key?("_dex_value")
248
+
249
+ result["_dex_value"]
250
+ end
251
+
252
+ def _symbolize_keys(value)
253
+ case value
254
+ when Hash
255
+ value.each_with_object({}) { |(k, v), h| h[k.to_sym] = _symbolize_keys(v) }
256
+ when Array
257
+ value.map { |v| _symbolize_keys(v) }
258
+ else
259
+ value
260
+ end
261
+ end
262
+
263
+ def _monotonic_now
264
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
265
+ end
266
+ end
267
+ end
268
+ end
data/lib/dex/operation.rb CHANGED
@@ -158,6 +158,7 @@ require_relative "operation/async_proxy"
158
158
  require_relative "operation/record_backend"
159
159
  require_relative "operation/transaction_adapter"
160
160
  require_relative "operation/jobs"
161
+ require_relative "operation/ticket"
161
162
  require_relative "operation/explain"
162
163
  require_relative "operation/export"
163
164
 
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class OperationFailed < StandardError
5
+ attr_reader :operation_name, :exception_class, :exception_message
6
+
7
+ def initialize(operation_name:, exception_class:, exception_message:)
8
+ @operation_name = operation_name
9
+ @exception_class = exception_class
10
+ @exception_message = exception_message
11
+ super("#{operation_name} failed with #{exception_class}: #{exception_message}")
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Timeout < StandardError
5
+ attr_reader :timeout, :ticket_id, :operation_name
6
+
7
+ def initialize(timeout:, ticket_id:, operation_name:)
8
+ @timeout = timeout.to_f
9
+ @ticket_id = ticket_id
10
+ @operation_name = operation_name
11
+ super("#{operation_name} did not complete within #{@timeout}s (ticket: #{ticket_id})")
12
+ end
13
+ end
14
+ end