dexkit 0.7.0 → 0.9.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +40 -7
  4. data/gemfiles/mongoid_no_ar.gemfile +10 -0
  5. data/gemfiles/mongoid_no_ar.gemfile.lock +232 -0
  6. data/guides/llm/EVENT.md +60 -5
  7. data/guides/llm/FORM.md +3 -3
  8. data/guides/llm/OPERATION.md +127 -18
  9. data/guides/llm/QUERY.md +3 -3
  10. data/lib/dex/event/bus.rb +7 -0
  11. data/lib/dex/event/export.rb +56 -0
  12. data/lib/dex/event/handler.rb +33 -0
  13. data/lib/dex/event/test_helpers.rb +88 -0
  14. data/lib/dex/event.rb +27 -0
  15. data/lib/dex/event_test_helpers.rb +1 -86
  16. data/lib/dex/form/uniqueness_validator.rb +17 -1
  17. data/lib/dex/operation/async_proxy.rb +1 -0
  18. data/lib/dex/operation/explain.rb +208 -0
  19. data/lib/dex/operation/export.rb +144 -0
  20. data/lib/dex/operation/guard_wrapper.rb +15 -4
  21. data/lib/dex/operation/lock_wrapper.rb +15 -2
  22. data/lib/dex/operation/once_wrapper.rb +23 -15
  23. data/lib/dex/operation/record_backend.rb +25 -0
  24. data/lib/dex/operation/record_wrapper.rb +29 -4
  25. data/lib/dex/operation/test_helpers/assertions.rb +335 -0
  26. data/lib/dex/operation/test_helpers/execution.rb +30 -0
  27. data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
  28. data/lib/dex/operation/test_helpers.rb +150 -0
  29. data/lib/dex/operation/transaction_adapter.rb +29 -68
  30. data/lib/dex/operation/transaction_wrapper.rb +10 -16
  31. data/lib/dex/operation.rb +46 -2
  32. data/lib/dex/props_setup.rb +25 -2
  33. data/lib/dex/query/backend.rb +13 -0
  34. data/lib/dex/query.rb +9 -5
  35. data/lib/dex/railtie.rb +84 -0
  36. data/lib/dex/ref_type.rb +4 -0
  37. data/lib/dex/registry.rb +63 -0
  38. data/lib/dex/test_helpers.rb +4 -139
  39. data/lib/dex/tool.rb +115 -0
  40. data/lib/dex/type_coercion.rb +4 -1
  41. data/lib/dex/type_serializer.rb +132 -0
  42. data/lib/dex/version.rb +1 -1
  43. data/lib/dexkit.rb +11 -5
  44. metadata +16 -5
  45. data/lib/dex/test_helpers/assertions.rb +0 -333
  46. data/lib/dex/test_helpers/execution.rb +0 -28
  47. data/lib/dex/test_helpers/stubbing.rb +0 -59
  48. /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module Explain
6
+ def explain(**kwargs)
7
+ error = nil
8
+ instance = begin
9
+ new(**kwargs)
10
+ rescue Literal::TypeError, ArgumentError => e
11
+ error = e
12
+ nil
13
+ end
14
+
15
+ info = {}
16
+ active = pipeline.steps.map(&:name).to_set
17
+
18
+ info[:operation] = name || "(anonymous)"
19
+ desc = description
20
+ info[:description] = desc if desc
21
+ info[:error] = "#{error.class}: #{error.message}" if error
22
+ info[:props] = _explain_props(instance)
23
+ info[:context] = _explain_context(instance, kwargs)
24
+ info[:guards] = active.include?(:guard) ? _explain_guards(instance) : { passed: true, results: [] }
25
+ info[:once] = active.include?(:once) ? _explain_once(instance) : { active: false }
26
+ info[:lock] = active.include?(:lock) ? _explain_lock(instance) : { active: false }
27
+ info[:record] = active.include?(:record) ? _explain_record : { enabled: false }
28
+ info[:transaction] = active.include?(:transaction) ? _explain_transaction : { enabled: false }
29
+ info[:rescue_from] = active.include?(:rescue) ? _explain_rescue : {}
30
+ info[:callbacks] = active.include?(:callback) ? _explain_callbacks : { before: 0, after: 0, around: 0 }
31
+
32
+ if instance
33
+ pipeline.steps.each do |step|
34
+ method_name = :"_#{step.name}_explain"
35
+ send(method_name, instance, info) if respond_to?(method_name, true)
36
+ end
37
+ end
38
+
39
+ info[:pipeline] = pipeline.steps.map(&:name)
40
+ info[:callable] = instance ? _explain_callable?(info) : false
41
+ info.freeze
42
+ end
43
+
44
+ private
45
+
46
+ def _explain_callable?(info)
47
+ return false unless info[:guards][:passed]
48
+ return false if info[:record][:enabled] && info[:record][:status] == :misconfigured
49
+
50
+ if info[:once][:active]
51
+ return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
52
+ end
53
+
54
+ true
55
+ end
56
+
57
+ ONCE_BLOCKING_STATUSES = %i[invalid pending misconfigured unavailable].freeze
58
+
59
+ def _explain_props(instance)
60
+ return {} unless respond_to?(:literal_properties)
61
+ return {} unless instance
62
+
63
+ literal_properties.each_with_object({}) do |prop, hash|
64
+ hash[prop.name] = instance.public_send(prop.name)
65
+ end
66
+ end
67
+
68
+ def _explain_context(instance, explicit_kwargs)
69
+ mappings = respond_to?(:context_mappings) ? context_mappings : {}
70
+ return { resolved: {}, mappings: {}, source: {} } if mappings.empty?
71
+
72
+ ambient = Dex.context
73
+ resolved = {}
74
+ source = {}
75
+
76
+ mappings.each do |prop_name, context_key|
77
+ resolved[prop_name] = instance.public_send(prop_name) if instance
78
+ source[prop_name] = if explicit_kwargs.key?(prop_name)
79
+ :explicit
80
+ elsif ambient.key?(context_key)
81
+ :ambient
82
+ elsif instance || _explain_prop_has_default?(prop_name)
83
+ :default
84
+ else
85
+ :missing
86
+ end
87
+ end
88
+
89
+ { resolved: resolved, mappings: mappings, source: source }
90
+ end
91
+
92
+ def _explain_guards(instance)
93
+ return { passed: false, results: [] } unless instance
94
+
95
+ all_results = instance.send(:_guard_evaluate_all)
96
+ results = all_results.map do |r|
97
+ entry = { name: r[:name], passed: r[:passed] }
98
+ entry[:message] = r[:message] if r[:message]
99
+ entry[:skipped] = true if r[:skipped]
100
+ entry
101
+ end
102
+ { passed: results.all? { |r| r[:passed] }, results: results }
103
+ end
104
+
105
+ def _explain_once(instance)
106
+ settings = settings_for(:once)
107
+ return { active: false } unless settings.fetch(:defined, false)
108
+
109
+ key = instance&.send(:_once_derive_key)
110
+ {
111
+ active: true,
112
+ key: key,
113
+ status: _explain_once_status(key),
114
+ expires_in: settings[:expires_in]
115
+ }
116
+ end
117
+
118
+ def _explain_once_status(key)
119
+ return :invalid if key.nil?
120
+ return :misconfigured if name.nil?
121
+ return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
122
+ return :unavailable unless Dex.record_backend
123
+ return :misconfigured unless Dex.record_backend.missing_fields(send(:_once_required_fields)).empty?
124
+
125
+ existing = Dex.record_backend.find_by_once_key(key)
126
+ return :exists if existing
127
+
128
+ if Dex.record_backend.has_field?("once_key_expires_at")
129
+ expired = Dex.record_backend.find_expired_once_key(key)
130
+ return :expired if expired
131
+ end
132
+
133
+ pending = Dex.record_backend.find_pending_once_key(key)
134
+ return :pending if pending
135
+
136
+ :fresh
137
+ end
138
+
139
+ def _explain_lock(instance)
140
+ settings = settings_for(:advisory_lock)
141
+ return { active: false } unless settings.fetch(:enabled, false)
142
+
143
+ key = if instance
144
+ instance.send(:_lock_key)
145
+ else
146
+ case settings[:key]
147
+ when String then settings[:key]
148
+ when nil then name
149
+ end
150
+ end
151
+
152
+ { active: true, key: key, timeout: settings[:timeout] }
153
+ end
154
+
155
+ def _explain_prop_has_default?(prop_name)
156
+ return false unless respond_to?(:literal_properties)
157
+
158
+ literal_properties.any? { |p| p.name == prop_name && p.default? }
159
+ end
160
+
161
+ def _explain_record
162
+ settings = settings_for(:record)
163
+ enabled = settings.fetch(:enabled, true) && !!Dex.record_backend && !!name
164
+ return { enabled: false } unless enabled
165
+
166
+ {
167
+ enabled: true,
168
+ params: settings.fetch(:params, true),
169
+ result: settings.fetch(:result, true)
170
+ }.tap do |entry|
171
+ missing = Dex.record_backend.missing_fields(send(:_record_required_fields))
172
+ if missing.empty?
173
+ entry[:status] = :ready
174
+ else
175
+ entry[:status] = :misconfigured
176
+ entry[:missing_fields] = missing
177
+ end
178
+ end
179
+ end
180
+
181
+ def _explain_transaction
182
+ settings = settings_for(:transaction)
183
+ return { enabled: false } unless settings.fetch(:enabled, true)
184
+
185
+ adapter_name = settings.fetch(:adapter, Dex.transaction_adapter)
186
+ adapter = Operation::TransactionAdapter.for(adapter_name)
187
+ { enabled: !adapter.nil? }
188
+ end
189
+
190
+ def _explain_rescue
191
+ handlers = respond_to?(:_rescue_handlers) ? _rescue_handlers : []
192
+ handlers.each_with_object({}) do |h, hash|
193
+ hash[h[:exception_class].name] = h[:code]
194
+ end
195
+ end
196
+
197
+ def _explain_callbacks
198
+ return { before: 0, after: 0, around: 0 } unless respond_to?(:_callback_list)
199
+
200
+ {
201
+ before: _callback_list(:before).size,
202
+ after: _callback_list(:after).size,
203
+ around: _callback_list(:around).size
204
+ }
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module Export
6
+ module_function
7
+
8
+ def build_hash(source, contract) # rubocop:disable Metrics/MethodLength
9
+ h = {}
10
+ h[:name] = source.name if source&.name
11
+ desc = source&.description
12
+ h[:description] = desc if desc
13
+ h[:params] = _serialize_params(source, contract.params)
14
+ h[:success] = TypeSerializer.to_string(contract.success) if contract.success
15
+ h[:errors] = contract.errors unless contract.errors.empty?
16
+ h[:guards] = contract.guards unless contract.guards.empty?
17
+ ctx = _serialize_context(source)
18
+ h[:context] = ctx unless ctx.empty?
19
+ h[:pipeline] = source.pipeline.steps.map(&:name) if source
20
+ h[:settings] = _serialize_settings(source) if source
21
+ h
22
+ end
23
+
24
+ def build_json_schema(source, contract, section: :params) # rubocop:disable Metrics/MethodLength
25
+ case section
26
+ when :params then _params_schema(source, contract)
27
+ when :success then _success_schema(source, contract)
28
+ when :errors then _errors_schema(source, contract)
29
+ when :full then _full_schema(source, contract)
30
+ else
31
+ raise ArgumentError,
32
+ "unknown section: #{section.inspect}. Known: :params, :success, :errors, :full"
33
+ end
34
+ end
35
+
36
+ def _serialize_params(source, params)
37
+ descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
38
+ params.each_with_object({}) do |(name, type), hash|
39
+ entry = { type: TypeSerializer.to_string(type), required: _required?(source, name) }
40
+ entry[:desc] = descs[name] if descs[name]
41
+ hash[name] = entry
42
+ end
43
+ end
44
+
45
+ def _required?(source, prop_name)
46
+ return true unless source&.respond_to?(:literal_properties)
47
+
48
+ prop = source.literal_properties.find { |p| p.name == prop_name }
49
+ return true unless prop
50
+
51
+ prop.required?
52
+ end
53
+
54
+ def _serialize_context(source)
55
+ source&.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
56
+ end
57
+
58
+ def _serialize_settings(source) # rubocop:disable Metrics/MethodLength
59
+ settings = {}
60
+
61
+ record_s = source.settings_for(:record)
62
+ settings[:record] = {
63
+ enabled: record_s.fetch(:enabled, true),
64
+ params: record_s.fetch(:params, true),
65
+ result: record_s.fetch(:result, true)
66
+ }
67
+
68
+ tx_s = source.settings_for(:transaction)
69
+ settings[:transaction] = { enabled: tx_s.fetch(:enabled, true) }
70
+
71
+ once_s = source.settings_for(:once)
72
+ settings[:once] = { defined: once_s.fetch(:defined, false) }
73
+
74
+ settings
75
+ end
76
+
77
+ def _params_schema(source, contract) # rubocop:disable Metrics/MethodLength
78
+ descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
79
+ properties = {}
80
+ required = []
81
+
82
+ contract.params.each do |name, type|
83
+ prop_desc = descs[name]
84
+ schema = TypeSerializer.to_json_schema(type, desc: prop_desc)
85
+ properties[name.to_s] = schema
86
+ required << name.to_s if _required?(source, name)
87
+ end
88
+
89
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
90
+ result[:title] = source.name if source&.name
91
+ desc = source&.description
92
+ result[:description] = desc if desc
93
+ result[:properties] = properties unless properties.empty?
94
+ result[:required] = required unless required.empty?
95
+ result[:additionalProperties] = false
96
+ result
97
+ end
98
+
99
+ def _success_schema(source, contract)
100
+ return {} unless contract.success
101
+
102
+ schema = TypeSerializer.to_json_schema(contract.success)
103
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
104
+ result[:title] = "#{source&.name} success" if source&.name
105
+ result.merge(schema)
106
+ end
107
+
108
+ def _errors_schema(source, contract)
109
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
110
+ result[:title] = "#{source&.name} errors" if source&.name
111
+ result[:type] = "object"
112
+
113
+ properties = {}
114
+ contract.errors.each do |code|
115
+ properties[code.to_s] = {
116
+ type: "object",
117
+ properties: {
118
+ code: { const: code.to_s },
119
+ message: { type: "string" },
120
+ details: { type: "object" }
121
+ }
122
+ }
123
+ end
124
+ result[:properties] = properties unless properties.empty?
125
+ result
126
+ end
127
+
128
+ def _full_schema(source, contract)
129
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
130
+ result[:title] = source.name if source&.name
131
+ result[:description] = "Operation contract"
132
+ result[:properties] = {
133
+ params: _params_schema(source, contract).except(:$schema),
134
+ success: _success_schema(source, contract).except(:$schema),
135
+ errors: _errors_schema(source, contract).except(:$schema)
136
+ }
137
+ result
138
+ end
139
+
140
+ private_class_method :_serialize_params, :_required?, :_serialize_context, :_serialize_settings,
141
+ :_params_schema, :_success_schema, :_errors_schema, :_full_schema
142
+ end
143
+ end
144
+ end
@@ -107,16 +107,17 @@ module Dex
107
107
 
108
108
  private
109
109
 
110
- def _guard_evaluate
110
+ def _guard_evaluate_all
111
111
  guards = self.class._guard_list
112
112
  return [] if guards.empty?
113
113
 
114
114
  blocked_names = Set.new
115
- failures = []
115
+ results = []
116
116
 
117
117
  guards.each do |guard|
118
118
  if guard.requires.any? { |dep| blocked_names.include?(dep) }
119
119
  blocked_names << guard.name
120
+ results << { name: guard.name, passed: false, skipped: true }
120
121
  next
121
122
  end
122
123
 
@@ -128,11 +129,21 @@ module Dex
128
129
 
129
130
  if threat
130
131
  blocked_names << guard.name
131
- failures << { guard: guard.name, message: guard.message || guard.name.to_s }
132
+ results << { name: guard.name, passed: false, message: guard.message || guard.name.to_s }
133
+ else
134
+ results << { name: guard.name, passed: true }
132
135
  end
133
136
  end
134
137
 
135
- failures
138
+ results
139
+ end
140
+
141
+ def _guard_evaluate
142
+ _guard_evaluate_all.filter_map do |r|
143
+ next if r[:passed] || r[:skipped]
144
+
145
+ { guard: r[:name], message: r[:message] }
146
+ end
136
147
  end
137
148
  end
138
149
  end
@@ -54,11 +54,24 @@ module Dex
54
54
  _lock_ensure_loaded!
55
55
  key = _lock_key
56
56
  ActiveRecord::Base.with_advisory_lock!(key, **_lock_options, &block)
57
- rescue WithAdvisoryLock::FailedToAcquireLock
58
- raise Dex::Error.new(:lock_timeout, "Could not acquire advisory lock: #{key}")
57
+ rescue => e
58
+ if defined?(WithAdvisoryLock::FailedToAcquireLock) && e.is_a?(WithAdvisoryLock::FailedToAcquireLock)
59
+ raise Dex::Error.new(:lock_timeout, "Could not acquire advisory lock: #{key}")
60
+ end
61
+
62
+ raise
59
63
  end
60
64
 
61
65
  def _lock_ensure_loaded!
66
+ unless defined?(ActiveRecord::Base)
67
+ raise LoadError, "advisory_lock requires ActiveRecord and is not supported in Mongoid-only apps."
68
+ end
69
+
70
+ unless defined?(WithAdvisoryLock::FailedToAcquireLock)
71
+ raise LoadError,
72
+ "with_advisory_lock gem is required for advisory locking. Add 'with_advisory_lock' to your Gemfile."
73
+ end
74
+
62
75
  return if ActiveRecord::Base.respond_to?(:with_advisory_lock!)
63
76
 
64
77
  raise LoadError,
@@ -44,6 +44,7 @@ module Dex
44
44
  else
45
45
  raise ArgumentError, "pass a String key or keyword arguments matching the once props"
46
46
  end
47
+ _once_validate_backend!
47
48
  Dex.record_backend.update_record_by_once_key(derived, once_key: nil)
48
49
  end
49
50
 
@@ -56,6 +57,27 @@ module Dex
56
57
 
57
58
  private
58
59
 
60
+ def _once_required_fields
61
+ fields =
62
+ if respond_to?(:_record_required_fields, true)
63
+ send(:_record_required_fields)
64
+ else
65
+ []
66
+ end
67
+
68
+ fields << "once_key"
69
+ fields << "once_key_expires_at" if settings_for(:once)[:expires_in]
70
+ fields.uniq
71
+ end
72
+
73
+ def _once_validate_backend!
74
+ unless Dex.record_backend
75
+ raise "once requires a record backend (configure Dex.record_class)"
76
+ end
77
+
78
+ Dex.record_backend.ensure_fields!(_once_required_fields, feature: "once")
79
+ end
80
+
59
81
  def _once_validate_props!(prop_names)
60
82
  return unless respond_to?(:literal_properties)
61
83
 
@@ -112,21 +134,7 @@ module Dex
112
134
  end
113
135
 
114
136
  def _once_ensure_backend!
115
- unless Dex.record_backend
116
- raise "once requires a record backend (configure Dex.record_class)"
117
- end
118
-
119
- return if self.class.instance_variable_defined?(:@_once_fields_checked)
120
-
121
- unless Dex.record_backend.has_field?("once_key")
122
- raise "once requires once_key column on #{Dex.record_class}. Run the migration to add it."
123
- end
124
-
125
- if self.class.settings_for(:once)[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
126
- raise "once with expires_in requires once_key_expires_at column on #{Dex.record_class}. Run the migration to add it."
127
- end
128
-
129
- self.class.instance_variable_set(:@_once_fields_checked, true)
137
+ self.class.send(:_once_validate_backend!)
130
138
  end
131
139
 
132
140
  def _once_derive_key
@@ -42,6 +42,10 @@ module Dex
42
42
  raise NotImplementedError
43
43
  end
44
44
 
45
+ def find_pending_once_key(key)
46
+ raise NotImplementedError
47
+ end
48
+
45
49
  def update_record_by_once_key(key, **attributes)
46
50
  raise NotImplementedError
47
51
  end
@@ -54,6 +58,19 @@ module Dex
54
58
  attributes.select { |key, _| has_field?(key.to_s) }
55
59
  end
56
60
 
61
+ def missing_fields(*fields)
62
+ fields.flatten.uniq.reject { |field_name| has_field?(field_name) }
63
+ end
64
+
65
+ def ensure_fields!(*fields, feature:)
66
+ missing = missing_fields(*fields)
67
+ return if missing.empty?
68
+
69
+ raise ArgumentError,
70
+ "Dex record_class #{record_class} is missing required attributes for #{feature}: #{missing.join(", ")}. " \
71
+ "Define these attributes on #{record_class} or disable #{feature}."
72
+ end
73
+
57
74
  def has_field?(field_name)
58
75
  raise NotImplementedError
59
76
  end
@@ -80,6 +97,10 @@ module Dex
80
97
  .first
81
98
  end
82
99
 
100
+ def find_pending_once_key(key)
101
+ record_class.where(once_key: key, status: %w[pending running]).first
102
+ end
103
+
83
104
  def update_record_by_once_key(key, **attributes)
84
105
  record = record_class.where(once_key: key, status: %w[completed error]).first
85
106
  record&.update!(safe_attributes(attributes))
@@ -119,6 +140,10 @@ module Dex
119
140
  ).first
120
141
  end
121
142
 
143
+ def find_pending_once_key(key)
144
+ record_class.where(:once_key => key, :status.in => %w[pending running]).first
145
+ end
146
+
122
147
  def update_record_by_once_key(key, **attributes)
123
148
  record = record_class.where(:once_key => key, :status.in => %w[completed error]).first
124
149
  record&.update!(safe_attributes(attributes))
@@ -5,6 +5,7 @@ module Dex
5
5
  extend Dex::Concern
6
6
 
7
7
  def _record_wrap
8
+ _record_validate_backend! if _record_enabled? || _record_has_pending_record?
8
9
  interceptor = Operation::HaltInterceptor.new { yield }
9
10
 
10
11
  if _record_has_pending_record?
@@ -34,6 +35,14 @@ module Dex
34
35
  "record expects true, false, or nil, got: #{enabled.inspect}"
35
36
  end
36
37
  end
38
+
39
+ def _record_required_fields(async: false)
40
+ settings = settings_for(:record)
41
+ fields = %w[name status performed_at error_code error_message error_details]
42
+ fields << "params" if async || settings.fetch(:params, true)
43
+ fields << "result" if settings.fetch(:result, true)
44
+ fields
45
+ end
37
46
  end
38
47
 
39
48
  private
@@ -46,6 +55,13 @@ module Dex
46
55
  record_settings.fetch(:enabled, true)
47
56
  end
48
57
 
58
+ def _record_validate_backend!(async: false)
59
+ Dex.record_backend.ensure_fields!(
60
+ self.class.send(:_record_required_fields, async: async),
61
+ feature: async ? "async recording" : "operation recording"
62
+ )
63
+ end
64
+
49
65
  def _record_has_pending_record?
50
66
  defined?(@_dex_record_id) && @_dex_record_id
51
67
  end
@@ -142,8 +158,8 @@ module Dex
142
158
  else
143
159
  case result
144
160
  when nil then nil
145
- when Hash then result
146
- else { _dex_value: result } # namespaced key so replay can distinguish wrapped primitives from user hashes
161
+ when Hash then _record_sanitize_value(result)
162
+ else { "_dex_value" => _record_sanitize_value(result) } # namespaced key so replay can distinguish wrapped primitives from user hashes
147
163
  end
148
164
  end
149
165
  end
@@ -160,10 +176,19 @@ module Dex
160
176
  case value
161
177
  when NilClass, String, Integer, Float, TrueClass, FalseClass then value
162
178
  when Symbol then value.to_s
163
- when Hash then value.transform_values { |v| _record_sanitize_value(v) }
179
+ when Hash
180
+ value.each_with_object({}) do |(key, nested_value), result|
181
+ result[key.to_s] = _record_sanitize_value(nested_value)
182
+ end
164
183
  when Array then value.map { |v| _record_sanitize_value(v) }
165
184
  when Exception then "#{value.class}: #{value.message}"
166
- else value.to_s
185
+ else
186
+ if value.respond_to?(:as_json)
187
+ serialized = value.as_json
188
+ return _record_sanitize_value(serialized) unless serialized.equal?(value)
189
+ end
190
+
191
+ value.to_s
167
192
  end
168
193
  end
169
194