dexkit 0.8.0 → 0.10.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +50 -18
  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 +41 -23
  7. data/guides/llm/FORM.md +202 -61
  8. data/guides/llm/OPERATION.md +49 -20
  9. data/guides/llm/QUERY.md +52 -2
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +85 -8
  13. data/lib/dex/event/handler.rb +18 -0
  14. data/lib/dex/event/metadata.rb +16 -9
  15. data/lib/dex/event/processor.rb +1 -1
  16. data/lib/dex/event/test_helpers.rb +88 -0
  17. data/lib/dex/event/trace.rb +14 -27
  18. data/lib/dex/event.rb +2 -7
  19. data/lib/dex/event_test_helpers.rb +1 -86
  20. data/lib/dex/form/context.rb +27 -0
  21. data/lib/dex/form/export.rb +128 -0
  22. data/lib/dex/form/nesting.rb +2 -0
  23. data/lib/dex/form/uniqueness_validator.rb +17 -1
  24. data/lib/dex/form.rb +119 -3
  25. data/lib/dex/id.rb +38 -0
  26. data/lib/dex/operation/async_proxy.rb +13 -2
  27. data/lib/dex/operation/explain.rb +11 -7
  28. data/lib/dex/operation/jobs.rb +5 -4
  29. data/lib/dex/operation/lock_wrapper.rb +15 -2
  30. data/lib/dex/operation/once_wrapper.rb +24 -15
  31. data/lib/dex/operation/record_backend.rb +15 -1
  32. data/lib/dex/operation/record_wrapper.rb +43 -8
  33. data/lib/dex/operation/test_helpers/assertions.rb +359 -0
  34. data/lib/dex/operation/test_helpers/execution.rb +30 -0
  35. data/lib/dex/operation/test_helpers/stubbing.rb +61 -0
  36. data/lib/dex/operation/test_helpers.rb +160 -0
  37. data/lib/dex/operation/trace_wrapper.rb +20 -0
  38. data/lib/dex/operation/transaction_adapter.rb +29 -68
  39. data/lib/dex/operation/transaction_wrapper.rb +10 -16
  40. data/lib/dex/operation.rb +2 -0
  41. data/lib/dex/query/backend.rb +13 -0
  42. data/lib/dex/query/export.rb +64 -0
  43. data/lib/dex/query.rb +50 -5
  44. data/lib/dex/ref_type.rb +4 -0
  45. data/lib/dex/test_helpers.rb +4 -139
  46. data/lib/dex/test_log.rb +62 -4
  47. data/lib/dex/trace.rb +291 -0
  48. data/lib/dex/type_coercion.rb +4 -1
  49. data/lib/dex/version.rb +1 -1
  50. data/lib/dexkit.rb +9 -5
  51. metadata +16 -5
  52. data/lib/dex/test_helpers/assertions.rb +0 -333
  53. data/lib/dex/test_helpers/execution.rb +0 -28
  54. data/lib/dex/test_helpers/stubbing.rb +0 -59
  55. /data/lib/dex/{event_test_helpers → event/test_helpers}/assertions.rb +0 -0
@@ -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
@@ -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
@@ -50,8 +50,12 @@ module Dex
50
50
  end
51
51
 
52
52
  def _build_query(model_class, column, value)
53
- if options[:case_sensitive] == false && value.is_a?(String) && model_class.respond_to?(:arel_table)
53
+ return model_class.where(column => value) unless options[:case_sensitive] == false && value.is_a?(String)
54
+
55
+ if model_class.respond_to?(:arel_table)
54
56
  model_class.where(model_class.arel_table[column].lower.eq(value.downcase))
57
+ elsif _mongoid_model_class?(model_class)
58
+ model_class.where(column => /\A#{Regexp.escape(value)}\z/i)
55
59
  else
56
60
  model_class.where(column => value)
57
61
  end
@@ -78,9 +82,21 @@ module Dex
78
82
  def _exclude_current_record(query, form)
79
83
  return query unless form.record&.persisted?
80
84
 
85
+ if _mongoid_record?(form.record)
86
+ return query.where(:_id.ne => form.record.id)
87
+ end
88
+
81
89
  pk = form.record.class.primary_key
82
90
  query.where.not(pk => form.record.public_send(pk))
83
91
  end
92
+
93
+ def _mongoid_model_class?(model_class)
94
+ defined?(Mongoid::Document) && model_class.include?(Mongoid::Document)
95
+ end
96
+
97
+ def _mongoid_record?(record)
98
+ _mongoid_model_class?(record.class)
99
+ end
84
100
  end
85
101
  end
86
102
  end
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
- super_result = super
99
- nested_result = _validate_nested(context)
100
- super_result && nested_result
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,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dex
6
+ module Id
7
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
8
+ TIMESTAMP_WIDTH = 8
9
+ RANDOM_WIDTH = 12
10
+
11
+ module_function
12
+
13
+ def generate(prefix)
14
+ "#{prefix}#{base58_encode(current_milliseconds, TIMESTAMP_WIDTH)}#{random_suffix(RANDOM_WIDTH)}"
15
+ end
16
+
17
+ def base58_encode(number, width = nil)
18
+ encoded = +""
19
+ value = number.to_i
20
+
21
+ loop do
22
+ value, remainder = value.divmod(ALPHABET.length)
23
+ encoded.prepend(ALPHABET[remainder])
24
+ break unless value.positive?
25
+ end
26
+
27
+ width ? encoded.rjust(width, ALPHABET[0]) : encoded
28
+ end
29
+
30
+ def random_suffix(width)
31
+ Array.new(width) { ALPHABET[SecureRandom.random_number(ALPHABET.length)] }.join
32
+ end
33
+
34
+ def current_milliseconds
35
+ Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
36
+ end
37
+ end
38
+ end
@@ -21,20 +21,31 @@ module Dex
21
21
 
22
22
  def enqueue_direct_job
23
23
  job = apply_options(Operation::DirectJob)
24
- payload = { class_name: operation_class_name, params: serialized_params }
24
+ payload = {
25
+ class_name: operation_class_name,
26
+ params: serialized_params,
27
+ trace: Dex::Trace.dump
28
+ }
25
29
  apply_once_payload!(payload)
26
30
  job.perform_later(**payload)
27
31
  end
28
32
 
29
33
  def enqueue_record_job
34
+ @operation.send(:_record_validate_backend!, async: true)
35
+ execution_id = Dex::Id.generate("op_")
30
36
  record = Dex.record_backend.create_record(
37
+ id: execution_id,
31
38
  name: operation_class_name,
32
39
  params: serialized_params,
33
40
  status: "pending"
34
41
  )
35
42
  begin
36
43
  job = apply_options(Operation::RecordJob)
37
- payload = { class_name: operation_class_name, record_id: record.id.to_s }
44
+ payload = {
45
+ class_name: operation_class_name,
46
+ record_id: record.id.to_s,
47
+ trace: Dex::Trace.dump
48
+ }
38
49
  apply_once_payload!(payload)
39
50
  job.perform_later(**payload)
40
51
  rescue => e
@@ -45,6 +45,7 @@ module Dex
45
45
 
46
46
  def _explain_callable?(info)
47
47
  return false unless info[:guards][:passed]
48
+ return false if info[:record][:enabled] && info[:record][:status] == :misconfigured
48
49
 
49
50
  if info[:once][:active]
50
51
  return false if ONCE_BLOCKING_STATUSES.include?(info[:once][:status])
@@ -119,12 +120,7 @@ module Dex
119
120
  return :misconfigured if name.nil?
120
121
  return :misconfigured unless pipeline.steps.any? { |s| s.name == :record }
121
122
  return :unavailable unless Dex.record_backend
122
- return :misconfigured unless Dex.record_backend.has_field?("once_key")
123
-
124
- settings = settings_for(:once)
125
- if settings[:expires_in] && !Dex.record_backend.has_field?("once_key_expires_at")
126
- return :misconfigured
127
- end
123
+ return :misconfigured unless Dex.record_backend.missing_fields(send(:_once_required_fields)).empty?
128
124
 
129
125
  existing = Dex.record_backend.find_by_once_key(key)
130
126
  return :exists if existing
@@ -171,7 +167,15 @@ module Dex
171
167
  enabled: true,
172
168
  params: settings.fetch(:params, true),
173
169
  result: settings.fetch(:result, true)
174
- }
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
175
179
  end
176
180
 
177
181
  def _explain_transaction
@@ -9,29 +9,30 @@ module Dex
9
9
  case name
10
10
  when :DirectJob
11
11
  const_set(:DirectJob, Class.new(ActiveJob::Base) do
12
- def perform(class_name:, params:, once_key: nil, once_bypass: false)
12
+ def perform(class_name:, params:, trace: nil, once_key: nil, once_bypass: false)
13
13
  klass = class_name.constantize
14
14
  op = klass.new(**klass.send(:_coerce_serialized_hash, params))
15
15
  op.once(once_key) if once_key
16
16
  op.once(nil) if once_bypass
17
- op.call
17
+ Dex::Trace.restore(trace) { op.call }
18
18
  end
19
19
  end)
20
20
  when :RecordJob
21
21
  const_set(:RecordJob, Class.new(ActiveJob::Base) do
22
- def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
22
+ def perform(class_name:, record_id:, trace: nil, once_key: nil, once_bypass: false)
23
23
  klass = class_name.constantize
24
24
  record = Dex.record_backend.find_record(record_id)
25
25
  params = klass.send(:_coerce_serialized_hash, record.params || {})
26
26
 
27
27
  op = klass.new(**params)
28
28
  op.instance_variable_set(:@_dex_record_id, record_id)
29
+ op.instance_variable_set(:@_dex_execution_id, record_id)
29
30
  op.once(once_key) if once_key
30
31
  op.once(nil) if once_bypass
31
32
 
32
33
  update_status(record_id, status: "running")
33
34
  pipeline_started = true
34
- op.call
35
+ Dex::Trace.restore(trace) { op.call }
35
36
  rescue => e
36
37
  # RecordWrapper handles failures during op.call via its own rescue.
37
38
  # This catches pre-pipeline failures (find_record, deserialization, etc.)
@@ -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
@@ -172,6 +180,7 @@ module Dex
172
180
  once_key: key, once_key_expires_at: expires_at)
173
181
  else
174
182
  record = Dex.record_backend.create_record(
183
+ id: @_dex_execution_id,
175
184
  name: self.class.name,
176
185
  once_key: key,
177
186
  once_key_expires_at: expires_at,
@@ -58,6 +58,19 @@ module Dex
58
58
  attributes.select { |key, _| has_field?(key.to_s) }
59
59
  end
60
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
+
61
74
  def has_field?(field_name)
62
75
  raise NotImplementedError
63
76
  end
@@ -143,7 +156,8 @@ module Dex
143
156
  end
144
157
 
145
158
  def has_field?(field_name)
146
- record_class.fields.key?(field_name.to_s)
159
+ field_name = field_name.to_s
160
+ record_class.fields.key?(field_name) || (field_name == "id" && record_class.fields.key?("_id"))
147
161
  end
148
162
  end
149
163
  end