dexkit 0.9.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/README.md +63 -254
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +25 -26
- data/guides/llm/FORM.md +200 -59
- data/guides/llm/OPERATION.md +115 -57
- data/guides/llm/QUERY.md +56 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +79 -11
- data/lib/dex/event/handler.rb +18 -1
- data/lib/dex/event/metadata.rb +15 -20
- data/lib/dex/event/processor.rb +2 -16
- data/lib/dex/event/test_helpers.rb +1 -1
- data/lib/dex/event.rb +3 -10
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +125 -0
- data/lib/dex/operation/async_proxy.rb +22 -4
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/once_wrapper.rb +1 -0
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/record_backend.rb +2 -1
- data/lib/dex/operation/record_wrapper.rb +14 -4
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -88
- data/lib/dex/operation/test_helpers.rb +11 -1
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation.rb +3 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +41 -0
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +22 -3
- metadata +12 -3
- data/lib/dex/event/trace.rb +0 -56
- data/lib/dex/event_test_helpers.rb +0 -3
data/lib/dex/event/metadata.rb
CHANGED
|
@@ -1,33 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "securerandom"
|
|
4
|
-
|
|
5
3
|
module Dex
|
|
6
4
|
class Event
|
|
7
5
|
class Metadata
|
|
8
|
-
attr_reader :id, :timestamp, :trace_id, :caused_by_id, :
|
|
6
|
+
attr_reader :id, :timestamp, :trace_id, :caused_by_id, :event_ancestry
|
|
9
7
|
|
|
10
|
-
def initialize(id:, timestamp:, trace_id:, caused_by_id:,
|
|
8
|
+
def initialize(id:, timestamp:, trace_id:, caused_by_id:, event_ancestry:)
|
|
11
9
|
@id = id
|
|
12
10
|
@timestamp = timestamp
|
|
13
11
|
@trace_id = trace_id
|
|
14
12
|
@caused_by_id = caused_by_id
|
|
15
|
-
@
|
|
13
|
+
@event_ancestry = event_ancestry
|
|
16
14
|
freeze
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
def self.build
|
|
20
|
-
id =
|
|
21
|
-
trace_id = Trace.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Event._warn("event_context failed: #{e.message}")
|
|
29
|
-
nil
|
|
30
|
-
end
|
|
17
|
+
def self.build
|
|
18
|
+
id = Dex::Id.generate("ev_")
|
|
19
|
+
trace_id = Dex::Trace.trace_id || Dex::Id.generate("tr_")
|
|
20
|
+
current_event = Dex::Trace.current_event_context
|
|
21
|
+
caused = current_event&.dig(:id)
|
|
22
|
+
ancestry = if current_event
|
|
23
|
+
Array(current_event[:event_ancestry]) + [caused].compact
|
|
24
|
+
else
|
|
25
|
+
[]
|
|
31
26
|
end
|
|
32
27
|
|
|
33
28
|
new(
|
|
@@ -35,7 +30,7 @@ module Dex
|
|
|
35
30
|
timestamp: Time.now.utc,
|
|
36
31
|
trace_id: trace_id,
|
|
37
32
|
caused_by_id: caused,
|
|
38
|
-
|
|
33
|
+
event_ancestry: ancestry
|
|
39
34
|
)
|
|
40
35
|
end
|
|
41
36
|
|
|
@@ -43,10 +38,10 @@ module Dex
|
|
|
43
38
|
h = {
|
|
44
39
|
"id" => @id,
|
|
45
40
|
"timestamp" => @timestamp.iso8601(6),
|
|
46
|
-
"trace_id" => @trace_id
|
|
41
|
+
"trace_id" => @trace_id,
|
|
42
|
+
"event_ancestry" => @event_ancestry
|
|
47
43
|
}
|
|
48
44
|
h["caused_by_id"] = @caused_by_id if @caused_by_id
|
|
49
|
-
h["context"] = @context if @context
|
|
50
45
|
h
|
|
51
46
|
end
|
|
52
47
|
end
|
data/lib/dex/event/processor.rb
CHANGED
|
@@ -7,13 +7,11 @@ module Dex
|
|
|
7
7
|
return super unless name == :Processor && defined?(ActiveJob::Base)
|
|
8
8
|
|
|
9
9
|
const_set(:Processor, Class.new(ActiveJob::Base) do
|
|
10
|
-
def perform(handler_class:, event_class:, payload:, metadata:, trace: nil,
|
|
11
|
-
restore_context(context)
|
|
12
|
-
|
|
10
|
+
def perform(handler_class:, event_class:, payload:, metadata:, trace: nil, attempt_number: 1)
|
|
13
11
|
handler = Object.const_get(handler_class)
|
|
14
12
|
retry_config = handler._event_handler_retry_config
|
|
15
13
|
|
|
16
|
-
Dex::
|
|
14
|
+
Dex::Trace.restore(trace) do
|
|
17
15
|
handler._event_handle_from_payload(event_class, payload, metadata)
|
|
18
16
|
end
|
|
19
17
|
rescue => _e
|
|
@@ -25,7 +23,6 @@ module Dex
|
|
|
25
23
|
payload: payload,
|
|
26
24
|
metadata: metadata,
|
|
27
25
|
trace: trace,
|
|
28
|
-
context: context,
|
|
29
26
|
attempt_number: attempt_number + 1
|
|
30
27
|
)
|
|
31
28
|
else
|
|
@@ -35,17 +32,6 @@ module Dex
|
|
|
35
32
|
|
|
36
33
|
private
|
|
37
34
|
|
|
38
|
-
def restore_context(context)
|
|
39
|
-
return unless context
|
|
40
|
-
|
|
41
|
-
restorer = Dex.configuration.restore_event_context
|
|
42
|
-
return unless restorer
|
|
43
|
-
|
|
44
|
-
restorer.call(context)
|
|
45
|
-
rescue => e
|
|
46
|
-
Dex::Event._warn("restore_event_context failed: #{e.message}")
|
|
47
|
-
end
|
|
48
|
-
|
|
49
35
|
def compute_delay(config, attempt)
|
|
50
36
|
wait = config[:wait]
|
|
51
37
|
case wait
|
data/lib/dex/event.rb
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
# Modules loaded before class body (no reference to Dex::Event needed)
|
|
4
4
|
require_relative "event/execution_state"
|
|
5
5
|
require_relative "event/metadata"
|
|
6
|
-
require_relative "event/trace"
|
|
7
6
|
require_relative "event/suppression"
|
|
8
7
|
|
|
9
8
|
module Dex
|
|
10
9
|
class Event
|
|
11
10
|
RESERVED_PROP_NAMES = %i[
|
|
12
|
-
id timestamp trace_id caused_by_id caused_by
|
|
11
|
+
id timestamp trace_id caused_by_id caused_by event_ancestry
|
|
13
12
|
context publish metadata sync
|
|
14
13
|
].to_set.freeze
|
|
15
14
|
|
|
@@ -67,8 +66,7 @@ module Dex
|
|
|
67
66
|
def timestamp = metadata.timestamp
|
|
68
67
|
def trace_id = metadata.trace_id
|
|
69
68
|
def caused_by_id = metadata.caused_by_id
|
|
70
|
-
def
|
|
71
|
-
def trace_frame = { id: id, trace_id: trace_id }
|
|
69
|
+
def event_ancestry = metadata.event_ancestry
|
|
72
70
|
|
|
73
71
|
# Publishing
|
|
74
72
|
def publish(sync: false)
|
|
@@ -77,7 +75,7 @@ module Dex
|
|
|
77
75
|
|
|
78
76
|
def self.publish(sync: false, caused_by: nil, **kwargs)
|
|
79
77
|
if caused_by
|
|
80
|
-
Trace.
|
|
78
|
+
Dex::Trace.with_event_context(caused_by) do
|
|
81
79
|
new(**kwargs).publish(sync: sync)
|
|
82
80
|
end
|
|
83
81
|
else
|
|
@@ -85,11 +83,6 @@ module Dex
|
|
|
85
83
|
end
|
|
86
84
|
end
|
|
87
85
|
|
|
88
|
-
# Tracing
|
|
89
|
-
def trace(&block)
|
|
90
|
-
Trace.with_event(self, &block)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
86
|
# Suppression
|
|
94
87
|
def self.suppress(*classes, &block)
|
|
95
88
|
Suppression.suppress(*classes, &block)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Form
|
|
5
|
+
# Context DSL for Form (ActiveModel::Attributes-backed).
|
|
6
|
+
#
|
|
7
|
+
# Same DSL as Operation/Event context, but checks attribute_names
|
|
8
|
+
# instead of literal_properties. Injection happens in Form#initialize.
|
|
9
|
+
module Context
|
|
10
|
+
extend Dex::Concern
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
include ContextDSL
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def _context_prop_declared?(name)
|
|
18
|
+
attribute_names.include?(name.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def _context_field_label
|
|
22
|
+
"field"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Form
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
TYPE_MAP = {
|
|
9
|
+
string: { type: "string" },
|
|
10
|
+
integer: { type: "integer" },
|
|
11
|
+
float: { type: "number" },
|
|
12
|
+
decimal: { type: "number" },
|
|
13
|
+
boolean: { type: "boolean" },
|
|
14
|
+
date: { type: "string", format: "date" },
|
|
15
|
+
datetime: { type: "string", format: "date-time" },
|
|
16
|
+
time: { type: "string", format: "time" }
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def build_hash(source)
|
|
20
|
+
h = _serialize_form_definition(source)
|
|
21
|
+
h[:name] = source.name if source.name
|
|
22
|
+
desc = source.description
|
|
23
|
+
h[:description] = desc if desc
|
|
24
|
+
h
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_json_schema(source) # rubocop:disable Metrics/MethodLength
|
|
28
|
+
properties = {}
|
|
29
|
+
required = []
|
|
30
|
+
|
|
31
|
+
source._field_registry.each do |name, field_def|
|
|
32
|
+
schema = _field_to_schema(field_def)
|
|
33
|
+
properties[name.to_s] = schema
|
|
34
|
+
required << name.to_s if field_def.required
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
_add_nested_properties(source, properties, required)
|
|
38
|
+
|
|
39
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
|
|
40
|
+
result[:title] = source.name if source.name
|
|
41
|
+
desc = source.description
|
|
42
|
+
result[:description] = desc if desc
|
|
43
|
+
result[:properties] = properties unless properties.empty?
|
|
44
|
+
result[:required] = required unless required.empty?
|
|
45
|
+
result[:additionalProperties] = false
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def _serialize_fields(source)
|
|
50
|
+
source._field_registry.each_with_object({}) do |(name, field_def), hash|
|
|
51
|
+
entry = { type: field_def.type, required: field_def.required }
|
|
52
|
+
entry[:desc] = field_def.desc if field_def.desc
|
|
53
|
+
entry[:default] = field_def.default if field_def.default?
|
|
54
|
+
hash[name] = entry
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def _serialize_form_definition(source)
|
|
59
|
+
h = { fields: _serialize_fields(source) }
|
|
60
|
+
nested = _serialize_nested(source)
|
|
61
|
+
h[:nested] = nested unless nested.empty?
|
|
62
|
+
h
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def _serialize_nested(source)
|
|
66
|
+
nested = {}
|
|
67
|
+
source._nested_ones.each do |name, klass|
|
|
68
|
+
nested[name] = { type: :one }.merge(_serialize_form_definition(klass))
|
|
69
|
+
end
|
|
70
|
+
source._nested_manys.each do |name, klass|
|
|
71
|
+
nested[name] = { type: :many }.merge(_serialize_form_definition(klass))
|
|
72
|
+
end
|
|
73
|
+
nested
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def _field_to_schema(field_def)
|
|
77
|
+
schema = TYPE_MAP[field_def.type]&.dup || {}
|
|
78
|
+
schema[:description] = field_def.desc if field_def.desc
|
|
79
|
+
schema[:default] = _coerce_default(field_def) if field_def.default?
|
|
80
|
+
schema
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def _coerce_default(field_def)
|
|
84
|
+
val = field_def.default
|
|
85
|
+
case field_def.type
|
|
86
|
+
when :integer then val.is_a?(Integer) ? val : val.to_i
|
|
87
|
+
when :float, :decimal then val.is_a?(Float) ? val : val.to_f
|
|
88
|
+
when :boolean then !!val
|
|
89
|
+
when :string, :date, :datetime, :time then val.to_s
|
|
90
|
+
else val
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def _nested_json_schema(klass)
|
|
95
|
+
properties = {}
|
|
96
|
+
required = []
|
|
97
|
+
|
|
98
|
+
klass._field_registry.each do |name, field_def|
|
|
99
|
+
schema = _field_to_schema(field_def)
|
|
100
|
+
properties[name.to_s] = schema
|
|
101
|
+
required << name.to_s if field_def.required
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
_add_nested_properties(klass, properties, required)
|
|
105
|
+
|
|
106
|
+
result = { type: "object" }
|
|
107
|
+
result[:properties] = properties unless properties.empty?
|
|
108
|
+
result[:required] = required unless required.empty?
|
|
109
|
+
result[:additionalProperties] = false
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def _add_nested_properties(source, properties, required)
|
|
114
|
+
source._nested_ones.each do |name, klass|
|
|
115
|
+
properties[name.to_s] = _nested_json_schema(klass)
|
|
116
|
+
required << name.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
source._nested_manys.each do |name, klass|
|
|
120
|
+
properties[name.to_s] = { type: "array", items: _nested_json_schema(klass) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private_class_method :_serialize_fields, :_serialize_form_definition, :_serialize_nested, :_field_to_schema,
|
|
125
|
+
:_coerce_default, :_nested_json_schema, :_add_nested_properties
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/dex/form/nesting.rb
CHANGED
|
@@ -70,6 +70,8 @@ module Dex
|
|
|
70
70
|
|
|
71
71
|
def _build_nested_class(name, class_name, &block)
|
|
72
72
|
klass = Class.new(Dex::Form, &block)
|
|
73
|
+
klass.instance_variable_set(:@_dex_nested_form, true)
|
|
74
|
+
Dex::Form.deregister(klass)
|
|
73
75
|
const_name = class_name || name.to_s.singularize.camelize
|
|
74
76
|
const_set(const_name, klass)
|
|
75
77
|
klass
|
data/lib/dex/form.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "active_model"
|
|
4
4
|
|
|
5
5
|
require_relative "form/nesting"
|
|
6
|
+
require_relative "form/context"
|
|
6
7
|
|
|
7
8
|
module Dex
|
|
8
9
|
class Form
|
|
@@ -15,8 +16,17 @@ module Dex
|
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
include Nesting
|
|
19
|
+
include Context
|
|
18
20
|
include Match
|
|
19
21
|
|
|
22
|
+
extend Registry
|
|
23
|
+
|
|
24
|
+
FIELD_DEFAULT_UNSET = Object.new.freeze
|
|
25
|
+
|
|
26
|
+
FieldDef = Data.define(:name, :type, :desc, :required, :default) do
|
|
27
|
+
def default? = !default.equal?(Dex::Form::FIELD_DEFAULT_UNSET)
|
|
28
|
+
end
|
|
29
|
+
|
|
20
30
|
class ValidationError < StandardError
|
|
21
31
|
attr_reader :form
|
|
22
32
|
|
|
@@ -27,6 +37,37 @@ module Dex
|
|
|
27
37
|
end
|
|
28
38
|
|
|
29
39
|
class << self
|
|
40
|
+
def _field_registry
|
|
41
|
+
@_field_registry ||= {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def _required_fields
|
|
45
|
+
_field_registry.each_with_object([]) do |(name, f), list|
|
|
46
|
+
list << name if f.required
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def field(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
|
|
51
|
+
raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
|
|
52
|
+
raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
|
|
53
|
+
raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
|
|
54
|
+
|
|
55
|
+
am_options = options.dup
|
|
56
|
+
am_options[:default] = default unless default.equal?(FIELD_DEFAULT_UNSET)
|
|
57
|
+
attribute name, type, **am_options
|
|
58
|
+
_field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: true, default: default)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def field?(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
|
|
62
|
+
raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
|
|
63
|
+
raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
|
|
64
|
+
raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
|
|
65
|
+
|
|
66
|
+
actual_default = default.equal?(FIELD_DEFAULT_UNSET) ? nil : default
|
|
67
|
+
attribute name, type, default: actual_default, **options
|
|
68
|
+
_field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: false, default: default)
|
|
69
|
+
end
|
|
70
|
+
|
|
30
71
|
def model(klass = nil)
|
|
31
72
|
if klass
|
|
32
73
|
raise ArgumentError, "model must be a Class, got #{klass.inspect}" unless klass.is_a?(Class)
|
|
@@ -42,9 +83,38 @@ module Dex
|
|
|
42
83
|
|
|
43
84
|
def inherited(subclass)
|
|
44
85
|
super
|
|
86
|
+
subclass.instance_variable_set(:@_field_registry, _field_registry.dup)
|
|
45
87
|
subclass.instance_variable_set(:@_nested_ones, _nested_ones.dup)
|
|
46
88
|
subclass.instance_variable_set(:@_nested_manys, _nested_manys.dup)
|
|
47
89
|
end
|
|
90
|
+
|
|
91
|
+
def _dex_nested_form?
|
|
92
|
+
!!instance_variable_get(:@_dex_nested_form)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Export
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
Export.build_hash(self)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_json_schema
|
|
102
|
+
Export.build_json_schema(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def export(format: :hash)
|
|
106
|
+
unless %i[hash json_schema].include?(format)
|
|
107
|
+
raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sorted = registry.reject { |klass| klass._dex_nested_form? }.sort_by(&:name)
|
|
111
|
+
sorted.map do |klass|
|
|
112
|
+
case format
|
|
113
|
+
when :hash then klass.to_h
|
|
114
|
+
when :json_schema then klass.to_json_schema
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
48
118
|
end
|
|
49
119
|
|
|
50
120
|
silence_redefinition_of_method :model_name
|
|
@@ -66,6 +136,18 @@ module Dex
|
|
|
66
136
|
# setters are assignable; everything else is silently dropped.
|
|
67
137
|
attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
|
|
68
138
|
attrs = (attributes || {}).transform_keys(&:to_s)
|
|
139
|
+
|
|
140
|
+
# Context injection — fill ambient values for unmapped keys
|
|
141
|
+
mappings = self.class.context_mappings
|
|
142
|
+
unless mappings.empty?
|
|
143
|
+
ambient = Dex.context
|
|
144
|
+
mappings.each do |attr_name, context_key|
|
|
145
|
+
str_name = attr_name.to_s
|
|
146
|
+
next if attrs.key?(str_name)
|
|
147
|
+
attrs[str_name] = ambient[context_key] if ambient.key?(context_key)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
69
151
|
record = attrs.delete("record")
|
|
70
152
|
@record = record if record.nil? || record.respond_to?(:persisted?)
|
|
71
153
|
provided_keys = attrs.keys
|
|
@@ -95,9 +177,10 @@ module Dex
|
|
|
95
177
|
end
|
|
96
178
|
|
|
97
179
|
def valid?(context = nil)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
180
|
+
super
|
|
181
|
+
_fix_boolean_presence_errors
|
|
182
|
+
nested_valid = _validate_nested(context)
|
|
183
|
+
errors.empty? && nested_valid
|
|
101
184
|
end
|
|
102
185
|
|
|
103
186
|
def to_h
|
|
@@ -111,8 +194,40 @@ module Dex
|
|
|
111
194
|
|
|
112
195
|
alias_method :to_hash, :to_h
|
|
113
196
|
|
|
197
|
+
validate :_validate_required_fields
|
|
198
|
+
|
|
114
199
|
private
|
|
115
200
|
|
|
201
|
+
def _validate_required_fields
|
|
202
|
+
explicit = self.class.validators
|
|
203
|
+
.select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
|
|
204
|
+
.reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
|
|
205
|
+
.flat_map(&:attributes).map(&:to_sym).to_set
|
|
206
|
+
|
|
207
|
+
self.class._required_fields.each do |name|
|
|
208
|
+
next if explicit.include?(name)
|
|
209
|
+
field_def = self.class._field_registry[name]
|
|
210
|
+
value = public_send(name)
|
|
211
|
+
blank = (field_def.type == :boolean) ? value.nil? : value.blank?
|
|
212
|
+
errors.add(name, :blank) if blank
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def _fix_boolean_presence_errors
|
|
217
|
+
explicit = self.class.validators
|
|
218
|
+
.select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
|
|
219
|
+
.reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
|
|
220
|
+
.flat_map(&:attributes).map(&:to_sym)
|
|
221
|
+
|
|
222
|
+
explicit.each do |name|
|
|
223
|
+
field_def = self.class._field_registry[name]
|
|
224
|
+
next unless field_def&.type == :boolean
|
|
225
|
+
next if public_send(name).nil?
|
|
226
|
+
|
|
227
|
+
errors.delete(name, :blank)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
116
231
|
def _extract_nested_attributes(attrs)
|
|
117
232
|
nested_keys = self.class._nested_ones.keys.map(&:to_s) +
|
|
118
233
|
self.class._nested_manys.keys.map(&:to_s)
|
|
@@ -140,3 +255,4 @@ module Dex
|
|
|
140
255
|
end
|
|
141
256
|
|
|
142
257
|
require_relative "form/uniqueness_validator"
|
|
258
|
+
require_relative "form/export"
|
data/lib/dex/id.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Dex
|
|
6
|
+
module Id
|
|
7
|
+
ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
8
|
+
BASE = ALPHABET.length
|
|
9
|
+
TIMESTAMP_WIDTH = 8
|
|
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)
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
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)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def base58_encode(number, width = nil)
|
|
58
|
+
encoded = +""
|
|
59
|
+
value = number.to_i
|
|
60
|
+
|
|
61
|
+
loop do
|
|
62
|
+
value, remainder = value.divmod(BASE)
|
|
63
|
+
encoded.prepend(ALPHABET[remainder])
|
|
64
|
+
break unless value.positive?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
width ? encoded.rjust(width, ALPHABET[0]) : encoded
|
|
68
|
+
end
|
|
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
|
+
|
|
83
|
+
def random_suffix(width)
|
|
84
|
+
Array.new(width) { ALPHABET[SecureRandom.random_number(BASE)] }.join
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def current_milliseconds
|
|
88
|
+
Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
|
|
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!
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -17,27 +17,45 @@ 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
|
|
23
29
|
job = apply_options(Operation::DirectJob)
|
|
24
|
-
payload = {
|
|
30
|
+
payload = {
|
|
31
|
+
class_name: operation_class_name,
|
|
32
|
+
params: serialized_params,
|
|
33
|
+
trace: Dex::Trace.dump
|
|
34
|
+
}
|
|
25
35
|
apply_once_payload!(payload)
|
|
26
|
-
job.perform_later(**payload)
|
|
36
|
+
job = job.perform_later(**payload)
|
|
37
|
+
Operation::Ticket.new(record: nil, job: job)
|
|
27
38
|
end
|
|
28
39
|
|
|
29
40
|
def enqueue_record_job
|
|
30
41
|
@operation.send(:_record_validate_backend!, async: true)
|
|
42
|
+
execution_id = Dex::Id.generate("op_")
|
|
31
43
|
record = Dex.record_backend.create_record(
|
|
44
|
+
id: execution_id,
|
|
32
45
|
name: operation_class_name,
|
|
33
46
|
params: serialized_params,
|
|
34
47
|
status: "pending"
|
|
35
48
|
)
|
|
36
49
|
begin
|
|
37
50
|
job = apply_options(Operation::RecordJob)
|
|
38
|
-
payload = {
|
|
51
|
+
payload = {
|
|
52
|
+
class_name: operation_class_name,
|
|
53
|
+
record_id: record.id.to_s,
|
|
54
|
+
trace: Dex::Trace.dump
|
|
55
|
+
}
|
|
39
56
|
apply_once_payload!(payload)
|
|
40
|
-
job.perform_later(**payload)
|
|
57
|
+
job = job.perform_later(**payload)
|
|
58
|
+
Operation::Ticket.new(record: record, job: job)
|
|
41
59
|
rescue => e
|
|
42
60
|
begin
|
|
43
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
|
|
127
|
+
"guard :#{guard.name} must return truthy/falsy, not call error!/success!"
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
if threat
|