zero_ruby 0.1.0.alpha2 → 0.1.0.alpha5

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -71
  3. data/lib/zero_ruby/configuration.rb +0 -5
  4. data/lib/zero_ruby/error_formatter.rb +173 -0
  5. data/lib/zero_ruby/errors.rb +10 -1
  6. data/lib/zero_ruby/input_object.rb +55 -93
  7. data/lib/zero_ruby/lmid_stores/active_record_store.rb +0 -1
  8. data/lib/zero_ruby/mutation.rb +235 -80
  9. data/lib/zero_ruby/push_processor.rb +54 -35
  10. data/lib/zero_ruby/schema.rb +38 -14
  11. data/lib/zero_ruby/type_names.rb +7 -15
  12. data/lib/zero_ruby/types.rb +52 -0
  13. data/lib/zero_ruby/typescript_generator.rb +167 -57
  14. data/lib/zero_ruby/version.rb +1 -1
  15. data/lib/zero_ruby.rb +12 -35
  16. metadata +46 -20
  17. data/lib/zero_ruby/argument.rb +0 -75
  18. data/lib/zero_ruby/types/base_type.rb +0 -54
  19. data/lib/zero_ruby/types/big_int.rb +0 -32
  20. data/lib/zero_ruby/types/boolean.rb +0 -30
  21. data/lib/zero_ruby/types/float.rb +0 -31
  22. data/lib/zero_ruby/types/id.rb +0 -33
  23. data/lib/zero_ruby/types/integer.rb +0 -31
  24. data/lib/zero_ruby/types/iso8601_date.rb +0 -43
  25. data/lib/zero_ruby/types/iso8601_date_time.rb +0 -43
  26. data/lib/zero_ruby/types/string.rb +0 -20
  27. data/lib/zero_ruby/validator.rb +0 -69
  28. data/lib/zero_ruby/validators/allow_blank_validator.rb +0 -31
  29. data/lib/zero_ruby/validators/allow_null_validator.rb +0 -26
  30. data/lib/zero_ruby/validators/exclusion_validator.rb +0 -29
  31. data/lib/zero_ruby/validators/format_validator.rb +0 -35
  32. data/lib/zero_ruby/validators/inclusion_validator.rb +0 -30
  33. data/lib/zero_ruby/validators/length_validator.rb +0 -42
  34. data/lib/zero_ruby/validators/numericality_validator.rb +0 -63
@@ -55,7 +55,6 @@ module ZeroRuby
55
55
  private
56
56
 
57
57
  def default_model_class
58
- require_relative "../zero_client"
59
58
  ZeroRuby::ZeroClient
60
59
  end
61
60
  end
@@ -1,38 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "argument"
3
+ require_relative "types"
4
+ require_relative "type_names"
4
5
  require_relative "errors"
5
- require_relative "validator"
6
+ require_relative "error_formatter"
6
7
 
7
8
  module ZeroRuby
8
- # Mixin that provides the argument DSL for mutations.
9
- # Inspired by graphql-ruby's HasArguments pattern.
10
- module HasArguments
11
- def self.included(base)
12
- base.extend(ClassMethods)
13
- end
9
+ # Base class for Zero mutations.
10
+ # Provides argument DSL with dry-types validation.
11
+ #
12
+ # Includes ZeroRuby::TypeNames for convenient type access via the Types module
13
+ # (e.g., Types::String, Types::ID, Types::Boolean).
14
+ #
15
+ # By default (auto_transact: true), the entire execute method runs inside
16
+ # a transaction with LMID tracking. For 3-phase control, set auto_transact false.
17
+ #
18
+ # @example Simple mutation (auto_transact: true, default)
19
+ # class WorkCreate < ZeroRuby::Mutation
20
+ # argument :id, Types::ID
21
+ # argument :title, Types::String.constrained(max_size: 200)
22
+ #
23
+ # def execute(id:, title:)
24
+ # authorize! Work, to: :create?
25
+ # Work.create!(id: id, title: title) # Runs inside auto-wrapped transaction
26
+ # end
27
+ # end
28
+ #
29
+ # @example 3-phase mutation (skip_auto_transaction)
30
+ # class WorkUpdate < ZeroRuby::Mutation
31
+ # skip_auto_transaction
32
+ #
33
+ # argument :id, Types::ID
34
+ # argument :title, Types::String
35
+ #
36
+ # def execute(id:, title:)
37
+ # work = Work.find(id)
38
+ # authorize! work, to: :update? # Pre-transaction
39
+ #
40
+ # transact do
41
+ # work.update!(title: title) # Transaction
42
+ # end
43
+ #
44
+ # notify_update(work) # Post-commit
45
+ # end
46
+ # end
47
+ class Mutation
48
+ include ZeroRuby::TypeNames
49
+
50
+ # The context hash containing current_user, etc.
51
+ attr_reader :ctx
52
+
53
+ # The validated arguments hash
54
+ attr_reader :args
55
+
56
+ class << self
57
+ # Opt-out of auto-transaction wrapping.
58
+ # By default, execute is wrapped in a transaction with LMID tracking.
59
+ # Call this to use explicit 3-phase model where you must call transact { }.
60
+ #
61
+ # @return [void]
62
+ def skip_auto_transaction
63
+ @skip_auto_transaction = true
64
+ end
65
+
66
+ # Check if auto-transaction is skipped for this mutation
67
+ # @return [Boolean] true if skip_auto_transaction was called
68
+ def skip_auto_transaction?
69
+ @skip_auto_transaction == true
70
+ end
14
71
 
15
- module ClassMethods
16
72
  # Declare an argument for this mutation
17
73
  # @param name [Symbol] The argument name
18
- # @param type [Class] The type class (e.g., ZeroRuby::Types::String)
19
- # @param required [Boolean] Whether the argument is required
20
- # @param validates [Hash] Validation configuration
21
- # @param default [Object] Default value if not provided
22
- # @param description [String] Description of the argument
23
- def argument(name, type, required: true, validates: nil, default: Argument::NOT_PROVIDED, description: nil, **options)
24
- arguments[name.to_sym] = Argument.new(
25
- name: name,
74
+ # @param type [Dry::Types::Type] The type (from ZeroRuby::Types or dry-types)
75
+ # @param description [String, nil] Optional description for documentation
76
+ def argument(name, type, description: nil)
77
+ arguments[name.to_sym] = {
26
78
  type: type,
27
- required: required,
28
- validates: validates,
29
- default: default,
30
79
  description: description,
31
- **options
32
- )
80
+ name: name.to_sym
81
+ }
33
82
  end
34
83
 
35
84
  # Get all declared arguments for this mutation (including inherited)
85
+ # @return [Hash<Symbol, Hash>] Map of argument name to config
36
86
  def arguments
37
87
  @arguments ||= if superclass.respond_to?(:arguments)
38
88
  superclass.arguments.dup
@@ -41,89 +91,141 @@ module ZeroRuby
41
91
  end
42
92
  end
43
93
 
44
- # Coerce and validate raw arguments
45
- # @param raw_args [Hash] Raw input arguments
46
- # @param ctx [Hash] The context hash
47
- # @return [Hash] Validated and coerced arguments
48
- # @raise [ZeroRuby::ValidationError] If validation fails
49
- def coerce_and_validate!(raw_args, ctx)
94
+ # Coerce and validate raw arguments.
95
+ # Collects ALL validation errors (missing fields, type coercion, constraints)
96
+ # and raises a single ValidationError with all issues.
97
+ #
98
+ # Uses type.try(value) which returns a Result instead of raising, allowing
99
+ # us to collect all errors in one pass rather than failing on the first one.
100
+ # Works for both Dry::Types (scalars) and Dry::Struct (InputObjects).
101
+ #
102
+ # @param raw_args [Hash] Raw input arguments (string keys from JSON)
103
+ # @return [Hash] Validated and coerced arguments (symbol keys, may contain InputObject instances)
104
+ # @raise [ZeroRuby::ValidationError] If any validation fails
105
+ def coerce_and_validate!(raw_args)
106
+ # Result hash: symbol keys → coerced values (strings, integers, InputObject instances, etc.)
107
+ # eg:
108
+ # raw_args: {"name" => "test", "count" => "5"} # string keys, raw values
109
+ # validated: {name: "test", count: 5} # symbol keys, coerced values
50
110
  validated = {}
51
111
  errors = []
52
112
 
53
- arguments.each do |name, arg|
54
- value = raw_args[name]
113
+ arguments.each do |name, config|
114
+ type = config[:type]
115
+ str_key = name.to_s
116
+ key_present = raw_args.key?(str_key)
117
+ value = raw_args[str_key]
118
+ is_input_object = input_object_type?(type)
55
119
 
56
- # Check required
57
- if arg.required? && value.nil? && !arg.has_default?
58
- errors << "#{name} is required"
120
+ # Missing key: use default if available, otherwise error if required
121
+ unless key_present
122
+ if has_default?(type)
123
+ validated[name] = get_default(type)
124
+ elsif required_type?(type)
125
+ errors << "#{name} is required"
126
+ end
127
+ # Optional fields without defaults are simply omitted from result
59
128
  next
60
129
  end
61
130
 
62
- # Apply default if needed, or nil for optional args without defaults
131
+ # Explicit null: InputObjects always allow nil (they handle optionality internally),
132
+ # scalars only allow nil if the type is optional
63
133
  if value.nil?
64
- validated[name] = arg.has_default? ? arg.default : nil
65
- next
66
- end
67
-
68
- # Type coercion
69
- begin
70
- coerced = arg.coerce(value, ctx)
71
- rescue CoercionError => e
72
- errors << "#{name}: #{e.message}"
134
+ if is_input_object || !required_type?(type)
135
+ validated[name] = nil
136
+ else
137
+ errors << "#{name} is required"
138
+ end
73
139
  next
74
140
  end
75
141
 
76
- # Run validators
77
- if arg.validators.any?
78
- validation_errors = Validator.validate!(arg.validators, nil, ctx, coerced)
79
- validation_errors.each do |err|
80
- errors << "#{name} #{err}"
81
- end
142
+ # Coerce value: type.try returns Result instead of raising, so we can
143
+ # collect all errors. Works for both Dry::Types and Dry::Struct.
144
+ result = type.try(value)
145
+ if result.failure?
146
+ errors << format_type_error(name, result.error, is_input_object)
147
+ else
148
+ validated[name] = result.input
82
149
  end
83
-
84
- validated[name] = coerced
85
150
  end
86
151
 
87
152
  raise ValidationError.new(errors) if errors.any?
88
153
  validated
89
154
  end
90
- end
91
- end
92
155
 
93
- # Base class for Zero mutations.
94
- # Provides argument DSL, validation, and error handling.
95
- #
96
- # @example
97
- # class WorkCreate < ZeroRuby::Mutation
98
- # argument :id, ID, required: true
99
- # argument :title, String, required: true,
100
- # validates: { length: { maximum: 200 } }
101
- #
102
- # def execute(id:, title:)
103
- # authorize! Work, to: :create?
104
- # Work.create!(id: id, title: title)
105
- # end
106
- # end
107
- class Mutation
108
- include HasArguments
109
- include TypeNames
156
+ private
110
157
 
111
- # The context hash containing current_user, etc.
112
- attr_reader :ctx
158
+ # Check if a type is an InputObject class
159
+ def input_object_type?(type)
160
+ type.is_a?(Class) && type < InputObject
161
+ end
162
+
163
+ # Check if a type has a default value
164
+ def has_default?(type)
165
+ type.respond_to?(:default?) && type.default?
166
+ end
167
+
168
+ # Get the default value for a type
169
+ def get_default(type)
170
+ return nil unless has_default?(type)
171
+ type[]
172
+ end
173
+
174
+ # Check if a type is required (not optional and not with default)
175
+ def required_type?(type)
176
+ return true unless type.respond_to?(:optional?)
177
+ !type.optional? && !has_default?(type)
178
+ end
179
+
180
+ # Format a type error (coercion or constraint failure)
181
+ # @param name [Symbol] The field name
182
+ # @param error [Exception] The error from try.failure
183
+ # @param is_input_object [Boolean] Whether this is an InputObject type
184
+ # @return [String] Formatted error message
185
+ def format_type_error(name, error, is_input_object = false)
186
+ if is_input_object && error.is_a?(Dry::Struct::Error)
187
+ # InputObject errors get prefixed with field name
188
+ ErrorFormatter.format_struct_error(error).map { |m| "#{name}.#{m}" }.first
189
+ else
190
+ message = case error
191
+ when Dry::Types::CoercionError
192
+ ErrorFormatter.format_coercion_error(error)
193
+ when Dry::Types::ConstraintError
194
+ ErrorFormatter.format_constraint_error(error)
195
+ else
196
+ error.message
197
+ end
198
+ "#{name}: #{message}"
199
+ end
200
+ end
201
+ end
113
202
 
114
203
  # Initialize a mutation with raw arguments and context
115
204
  # @param raw_args [Hash] Raw input arguments (will be coerced and validated)
116
205
  # @param ctx [Hash] The context hash
117
206
  def initialize(raw_args, ctx)
118
207
  @ctx = ctx
119
- @args = self.class.coerce_and_validate!(raw_args, ctx)
208
+ @args = self.class.coerce_and_validate!(raw_args)
120
209
  end
121
210
 
122
211
  # Execute the mutation
212
+ # @param transact_proc [Proc] Block that wraps transactional work (internal use)
123
213
  # @return [Hash] Empty hash on success, or {data: ...} if execute returns a Hash
124
214
  # @raise [ZeroRuby::Error] On failure (formatted at boundary)
125
- def call
126
- data = execute(**@args)
215
+ # @raise [ZeroRuby::TransactNotCalledError] If skip_auto_transaction and transact not called
216
+ def call(&transact_proc)
217
+ @transact_proc = transact_proc
218
+ @transact_called = false
219
+
220
+ if self.class.skip_auto_transaction?
221
+ # Manual mode: Use defined mutation calls transact {}
222
+ data = execute(**@args)
223
+ raise TransactNotCalledError.new unless @transact_called
224
+ else
225
+ # Auto mode: wrap entire execute in transaction
226
+ data = transact_proc.call { execute(**@args) }
227
+ end
228
+
127
229
  result = {}
128
230
  result[:data] = data if data.is_a?(Hash) && !data.empty?
129
231
  result
@@ -131,12 +233,65 @@ module ZeroRuby
131
233
 
132
234
  private
133
235
 
236
+ # Wrap database operations in a transaction with LMID tracking.
237
+ # Used by user defined mutation.
238
+ #
239
+ # Behavior depends on skip_auto_transaction:
240
+ # - Default (no skip) - Just executes the block (already in transaction)
241
+ # - skip_auto_transaction - Wraps block in transaction via transact_proc
242
+ #
243
+ # For skip_auto_transaction mutations, you MUST call this method.
244
+ # For default mutations, calling this is optional (no-op, just runs block).
245
+ #
246
+ # @yield Block containing database operations
247
+ # @return [Object] Result of the block
248
+ def transact(&block)
249
+ raise "transact requires a block" unless block_given?
250
+ @transact_called = true
251
+
252
+ if self.class.skip_auto_transaction?
253
+ # Manual mode - actually call the transact_proc to start transaction
254
+ @transact_proc.call(&block)
255
+ else
256
+ # Auto mode - already in transaction, just execute the block
257
+ block.call
258
+ end
259
+ end
260
+
261
+ # Convenience method to access current_user from context.
262
+ # Override in your ApplicationMutation if you need different behavior.
263
+ def current_user
264
+ ctx[:current_user]
265
+ end
266
+
134
267
  # Implement this method in subclasses to define mutation logic.
135
- # Arguments declared with `argument` are passed as keyword arguments.
136
- # Access context via ctx[:key] (e.g., ctx[:current_user]).
137
- # No return value needed - just perform the mutation.
138
- # Raise an exception to signal failure.
139
- def execute(**)
268
+ # Arguments are passed as keyword arguments matching your declared arguments.
269
+ # Access context via ctx[:key] or use the current_user helper.
270
+ #
271
+ # By default (auto_transact: true), the entire execute method runs inside
272
+ # a transaction with LMID tracking. Just write your database operations directly.
273
+ #
274
+ # For 3-phase control, use `skip_auto_transaction` and call transact { ... }:
275
+ # - Pre-transaction: code before transact (auth, validation)
276
+ # - Transaction: code inside transact { } (database operations)
277
+ # - Post-commit: code after transact returns (side effects)
278
+ #
279
+ # @example Simple mutation (default auto_transact: true)
280
+ # def execute(id:, title:)
281
+ # authorize! Post, to: :create?
282
+ # Post.create!(id: id, title: title)
283
+ # end
284
+ #
285
+ # @example 3-phase mutation (skip_auto_transaction)
286
+ # def execute(id:, title:)
287
+ # authorize! Post, to: :create? # Pre-transaction
288
+ # result = transact do
289
+ # Post.create!(id: id, title: title) # Transaction
290
+ # end
291
+ # NotificationService.notify(result.id) # Post-commit
292
+ # {id: result.id}
293
+ # end
294
+ def execute(**args)
140
295
  raise NotImplementedError, "Subclasses must implement #execute"
141
296
  end
142
297
  end
@@ -15,15 +15,13 @@ module ZeroRuby
15
15
  # @see https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts
16
16
  # @see https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/zql-database.ts
17
17
  class PushProcessor
18
- attr_reader :schema, :lmid_store, :max_retries
18
+ attr_reader :schema, :lmid_store
19
19
 
20
20
  # @param schema [Class] The schema class for mutation processing
21
21
  # @param lmid_store [LmidStore] The LMID store instance
22
- # @param max_retries [Integer] Maximum retry attempts for retryable errors
23
- def initialize(schema:, lmid_store:, max_retries: 3)
22
+ def initialize(schema:, lmid_store:)
24
23
  @schema = schema
25
24
  @lmid_store = lmid_store
26
- @max_retries = max_retries
27
25
  end
28
26
 
29
27
  # Process a Zero push request
@@ -49,7 +47,7 @@ module ZeroRuby
49
47
  message: e.message,
50
48
  mutationIDs: unprocessed_ids
51
49
  }
52
- rescue DatabaseError => e
50
+ rescue TransactionError => e
53
51
  # Database errors trigger top-level PushFailed per Zero protocol
54
52
  unprocessed_ids = mutations[index..].map { |m| {id: m["id"], clientID: m["clientID"]} }
55
53
  return {
@@ -66,30 +64,66 @@ module ZeroRuby
66
64
 
67
65
  private
68
66
 
69
- # Process a single mutation with LMID validation and transaction support.
70
- # Uses atomic increment-then-validate pattern matching Zero's TypeScript implementation.
67
+ # Process a single mutation with LMID validation, transaction support, and phase tracking.
68
+ #
69
+ # The Mutation#call method decides whether to auto-wrap execute in a transaction
70
+ # (default behavior) or pass control to user code (skip_auto_transaction mode).
71
+ #
72
+ # Phase tracking enables correct LMID semantics:
73
+ # - Pre-transaction error: LMID advanced in separate transaction
74
+ # - Transaction error: LMID advanced in separate transaction (original tx rolled back)
75
+ # - Post-commit error: LMID already committed with transaction
71
76
  def process_mutation_with_lmid(mutation_data, client_group_id, context)
72
77
  mutation_id = mutation_data["id"]
73
78
  client_id = mutation_data["clientID"]
79
+ mutation_id_obj = {id: mutation_id, clientID: client_id}
80
+ mutation_name = mutation_data["name"]
74
81
 
75
- mutation_id_obj = {
76
- id: mutation_id,
77
- clientID: client_id
78
- }
82
+ handler_class = schema.handler_for(mutation_name)
83
+ raise MutationNotFoundError.new(mutation_name) unless handler_class
79
84
 
80
- lmid_store.transaction do
81
- # Atomically increment LMID first, then validate.
82
- # This matches the TypeScript implementation's approach for minimal lock duration.
83
- last_mutation_id = lmid_store.fetch_and_increment(client_group_id, client_id)
84
- check_lmid!(client_id, mutation_id, last_mutation_id)
85
+ phase = :pre_transaction
85
86
 
86
- result = execute_with_retry(mutation_data, context)
87
- {id: mutation_id_obj, result: result}
88
- end
89
- rescue OutOfOrderMutationError, DatabaseError
87
+ transact_proc = proc { |&user_block|
88
+ phase = :transaction
89
+ result = lmid_store.transaction do
90
+ last_mutation_id = lmid_store.fetch_and_increment(client_group_id, client_id)
91
+ check_lmid!(client_id, mutation_id, last_mutation_id)
92
+ user_block.call
93
+ end
94
+ phase = :post_commit
95
+ result
96
+ }
97
+
98
+ result = schema.execute_mutation(mutation_data, context, &transact_proc)
99
+ {id: mutation_id_obj, result: result}
100
+ rescue MutationNotFoundError, MutationAlreadyProcessedError => e
101
+ # Known skip conditions - return error response, batch continues
102
+ {id: mutation_id_obj, result: format_error_response(e)}
103
+ rescue OutOfOrderMutationError, TransactionError
104
+ # Batch-terminating errors - bubble up to process() for PushFailed response
90
105
  raise
91
106
  rescue ZeroRuby::Error => e
107
+ # Application errors - advance LMID based on phase, return error response
108
+ # Pre-transaction/transaction: LMID advanced separately
109
+ # Post-commit: LMID already committed with transaction
110
+ if phase != :post_commit
111
+ persist_lmid_on_application_error(client_group_id, client_id)
112
+ end
92
113
  {id: mutation_id_obj, result: format_error_response(e)}
114
+ rescue => e
115
+ # Unexpected errors - wrap and bubble up as batch-terminating
116
+ raise TransactionError.new("Transaction failed: #{e.message}")
117
+ end
118
+
119
+ # Persist LMID advancement after an application error.
120
+ # Called for pre-transaction and transaction errors to prevent replay attacks.
121
+ def persist_lmid_on_application_error(client_group_id, client_id)
122
+ lmid_store.transaction do
123
+ lmid_store.fetch_and_increment(client_group_id, client_id)
124
+ end
125
+ rescue => e
126
+ warn "Failed to persist LMID after application error: #{e.message}"
93
127
  end
94
128
 
95
129
  # Validate LMID against the post-increment value.
@@ -113,21 +147,6 @@ module ZeroRuby
113
147
  end
114
148
  end
115
149
 
116
- # Execute mutation with retry logic for app errors
117
- def execute_with_retry(mutation_data, context)
118
- attempts = 0
119
-
120
- loop do
121
- attempts += 1
122
-
123
- begin
124
- return schema.execute_mutation(mutation_data, context)
125
- rescue ZeroRuby::Error => e
126
- raise e unless attempts < max_retries
127
- end
128
- end
129
- end
130
-
131
150
  # Format an error into Zero protocol response
132
151
  def format_error_response(error)
133
152
  result = {error: error.error_type}
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "error_formatter"
4
5
 
5
6
  module ZeroRuby
6
7
  # Schema class for registering and processing Zero mutations.
@@ -28,6 +29,13 @@ module ZeroRuby
28
29
  end
29
30
  end
30
31
 
32
+ # Get handler class for a mutation name
33
+ # @param name [String] The mutation name
34
+ # @return [Class, nil] The handler class or nil if not found
35
+ def handler_for(name)
36
+ mutations[normalize_mutation_name(name)]
37
+ end
38
+
31
39
  # Generate TypeScript type definitions from registered mutations
32
40
  # @return [String] TypeScript type definitions
33
41
  def to_typescript
@@ -67,8 +75,7 @@ module ZeroRuby
67
75
  store = lmid_store || ZeroRuby.configuration.lmid_store_instance
68
76
  processor = PushProcessor.new(
69
77
  schema: self,
70
- lmid_store: store,
71
- max_retries: ZeroRuby.configuration.max_retry_attempts
78
+ lmid_store: store
72
79
  )
73
80
  processor.process(push_data, context)
74
81
  rescue ParseError => e
@@ -85,32 +92,45 @@ module ZeroRuby
85
92
  # Used by PushProcessor for LMID-tracked mutations.
86
93
  # @param mutation_data [Hash] The mutation data from Zero
87
94
  # @param context [Hash] Context hash to pass to mutations
95
+ # @param transact [Proc] Block that wraps transactional work
88
96
  # @return [Hash] Empty hash on success
89
97
  # @raise [MutationNotFoundError] If the mutation is not registered
90
98
  # @raise [ZeroRuby::Error] If the mutation fails
91
- def execute_mutation(mutation_data, context)
99
+ def execute_mutation(mutation_data, context, &transact)
92
100
  name = normalize_mutation_name(mutation_data["name"])
93
- raw_args = extract_args(mutation_data)
94
- params = transform_keys(raw_args)
95
-
96
- ctx = context.freeze
97
101
  handler = mutations[name]
98
-
99
102
  raise MutationNotFoundError.new(name) unless handler
100
103
 
101
- handler.new(params, ctx).call
104
+ raw_args = extract_args(mutation_data)
105
+ params = transform_keys(raw_args)
106
+
107
+ handler.new(params, context).call(&transact)
108
+ rescue Dry::Struct::Error => e
109
+ raise ValidationError.new(ErrorFormatter.format_struct_error(e))
110
+ rescue Dry::Types::CoercionError => e
111
+ raise ValidationError.new([ErrorFormatter.format_coercion_error(e)])
112
+ rescue Dry::Types::ConstraintError => e
113
+ raise ValidationError.new([ErrorFormatter.format_constraint_error(e)])
102
114
  end
103
115
 
104
116
  private
105
117
 
106
- # Validate push data structure
118
+ # Validate push data structure per Zero protocol
119
+ # Required fields: clientGroupID, mutations, pushVersion, timestamp, requestID
107
120
  # @raise [ParseError] If push data is malformed
108
121
  def validate_push_structure!(push_data)
109
122
  unless push_data.is_a?(Hash)
110
123
  raise ParseError.new("Push data must be a hash")
111
124
  end
112
- unless push_data.key?("clientGroupID")
113
- raise ParseError.new("Missing required field: clientGroupID")
125
+
126
+ %w[clientGroupID mutations pushVersion timestamp requestID].each do |field|
127
+ unless push_data.key?(field)
128
+ raise ParseError.new("Missing required field: #{field}")
129
+ end
130
+ end
131
+
132
+ unless push_data["mutations"].is_a?(Array)
133
+ raise ParseError.new("Field 'mutations' must be an array")
114
134
  end
115
135
  end
116
136
 
@@ -129,12 +149,16 @@ module ZeroRuby
129
149
  args.is_a?(Array) ? (args.first || {}) : args
130
150
  end
131
151
 
132
- # Transform camelCase string keys to snake_case symbols (deep)
152
+ # Transform camelCase string keys to snake_case strings (deep).
153
+ # Keys are kept as strings to prevent symbol table DoS attacks.
154
+ # Symbolization happens later in coerce_and_validate! using schema-defined keys.
155
+ # @param object [Object] The object to transform
156
+ # @return [Object] Transformed object with string keys
133
157
  def transform_keys(object)
134
158
  case object
135
159
  when Hash
136
160
  object.each_with_object({}) do |(key, value), result|
137
- new_key = key.to_s.gsub(/([A-Z])/, '_\1').downcase.delete_prefix("_").to_sym
161
+ new_key = key.to_s.gsub(/([A-Z])/, '_\1').downcase.delete_prefix("_")
138
162
  result[new_key] = transform_keys(value)
139
163
  end
140
164
  when Array
@@ -1,24 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZeroRuby
4
- # Provides shorthand constants for ZeroRuby types.
5
- # Include this module to use ID, Boolean, etc. without the ZeroRuby::Types:: prefix.
6
- #
7
- # This is automatically included in Mutation and InputObject, so you can write:
4
+ # Provides access to ZeroRuby::Types via the Types constant.
5
+ # Automatically included in Mutation and InputObject classes.
8
6
  #
7
+ # @example Usage in mutations
9
8
  # class PostCreate < ZeroRuby::Mutation
10
- # argument :id, ID, required: true
11
- # argument :title, String, required: true
12
- # argument :active, Boolean, required: true
9
+ # argument :id, Types::ID
10
+ # argument :title, Types::String
11
+ # argument :active, Types::Boolean
13
12
  # end
14
- #
15
- # Note: String, Integer, and Float work automatically because Ruby's built-in
16
- # classes are resolved to ZeroRuby types by the argument system.
17
13
  module TypeNames
18
- ID = ZeroRuby::Types::ID
19
- Boolean = ZeroRuby::Types::Boolean
20
- BigInt = ZeroRuby::Types::BigInt
21
- ISO8601Date = ZeroRuby::Types::ISO8601Date
22
- ISO8601DateTime = ZeroRuby::Types::ISO8601DateTime
14
+ Types = ZeroRuby::Types
23
15
  end
24
16
  end