zero_ruby 0.1.0.alpha2 → 0.1.0.alpha4

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 +124 -70
  3. data/lib/zero_ruby/configuration.rb +0 -5
  4. data/lib/zero_ruby/error_formatter.rb +171 -0
  5. data/lib/zero_ruby/errors.rb +10 -1
  6. data/lib/zero_ruby/input_object.rb +56 -93
  7. data/lib/zero_ruby/lmid_stores/active_record_store.rb +0 -1
  8. data/lib/zero_ruby/mutation.rb +201 -77
  9. data/lib/zero_ruby/push_processor.rb +108 -34
  10. data/lib/zero_ruby/schema.rb +38 -14
  11. data/lib/zero_ruby/type_names.rb +13 -8
  12. data/lib/zero_ruby/types.rb +54 -0
  13. data/lib/zero_ruby/typescript_generator.rb +126 -58
  14. data/lib/zero_ruby/version.rb +1 -1
  15. data/lib/zero_ruby.rb +11 -34
  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
@@ -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
6
 
7
7
  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
8
+ # Base class for Zero mutations.
9
+ # Provides argument DSL with dry-types validation.
10
+ #
11
+ # Includes ZeroRuby::TypeNames for convenient type access:
12
+ # - ID, Boolean, ISO8601Date, ISO8601DateTime (direct constants)
13
+ # - Types::String, Types::Integer, Types::Float (via Types module)
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, 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, 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,88 +91,111 @@ module ZeroRuby
41
91
  end
42
92
  end
43
93
 
44
- # Coerce and validate raw arguments
94
+ # Coerce and validate raw arguments.
95
+ # Collects "field is required" errors for all missing required fields,
96
+ # but lets Dry exceptions bubble up for type/constraint errors.
97
+ #
45
98
  # @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)
99
+ # @return [Hash] Validated and coerced arguments (may contain InputObject instances)
100
+ # @raise [ZeroRuby::ValidationError] If required fields are missing
101
+ # @raise [Dry::Types::CoercionError] If type coercion fails
102
+ # @raise [Dry::Types::ConstraintError] If constraint validation fails
103
+ # @raise [Dry::Struct::Error] If InputObject validation fails
104
+ def coerce_and_validate!(raw_args)
50
105
  validated = {}
51
106
  errors = []
52
107
 
53
- arguments.each do |name, arg|
54
- value = raw_args[name]
108
+ arguments.each do |name, config|
109
+ type = config[:type]
110
+ str_key = name.to_s
111
+ key_present = raw_args.key?(str_key)
112
+ value = raw_args[str_key]
55
113
 
56
- # Check required
57
- if arg.required? && value.nil? && !arg.has_default?
58
- errors << "#{name} is required"
59
- next
60
- end
61
-
62
- # Apply default if needed, or nil for optional args without defaults
63
- if value.nil?
64
- validated[name] = arg.has_default? ? arg.default : nil
114
+ # Handle InputObject types (subclasses of InputObject)
115
+ if input_object_type?(type)
116
+ if value.nil? && key_present
117
+ # Explicit nil
118
+ validated[name] = nil
119
+ elsif value.nil? && !key_present
120
+ # Missing key - check if required
121
+ if required_type?(type)
122
+ errors << "#{name} is required"
123
+ end
124
+ # Skip if optional and not provided
125
+ else
126
+ # Let Dry::Struct errors bubble up
127
+ validated[name] = type.new(value)
128
+ end
65
129
  next
66
130
  end
67
131
 
68
- # Type coercion
69
- begin
70
- coerced = arg.coerce(value, ctx)
71
- rescue CoercionError => e
72
- errors << "#{name}: #{e.message}"
132
+ # Handle missing key
133
+ if !key_present
134
+ if has_default?(type)
135
+ validated[name] = get_default(type)
136
+ elsif required_type?(type)
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}"
142
+ # Handle explicit null
143
+ if value.nil?
144
+ if required_type?(type)
145
+ errors << "#{name} is required"
146
+ else
147
+ validated[name] = nil
81
148
  end
149
+ next
82
150
  end
83
151
 
84
- validated[name] = coerced
152
+ # Coerce and validate value - let Dry exceptions bubble up
153
+ validated[name] = type[value]
85
154
  end
86
155
 
87
156
  raise ValidationError.new(errors) if errors.any?
88
157
  validated
89
158
  end
90
- end
91
- end
92
159
 
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
160
+ private
110
161
 
111
- # The context hash containing current_user, etc.
112
- attr_reader :ctx
162
+ # Check if a type is an InputObject class
163
+ def input_object_type?(type)
164
+ type.is_a?(Class) && type < InputObject
165
+ end
166
+
167
+ # Check if a type has a default value
168
+ def has_default?(type)
169
+ type.respond_to?(:default?) && type.default?
170
+ end
171
+
172
+ # Get the default value for a type
173
+ def get_default(type)
174
+ return nil unless has_default?(type)
175
+ type[]
176
+ end
177
+
178
+ # Check if a type is required (not optional and not with default)
179
+ def required_type?(type)
180
+ return true unless type.respond_to?(:optional?)
181
+ !type.optional? && !has_default?(type)
182
+ end
183
+ end
113
184
 
114
185
  # Initialize a mutation with raw arguments and context
115
186
  # @param raw_args [Hash] Raw input arguments (will be coerced and validated)
116
187
  # @param ctx [Hash] The context hash
117
188
  def initialize(raw_args, ctx)
118
189
  @ctx = ctx
119
- @args = self.class.coerce_and_validate!(raw_args, ctx)
190
+ @args = self.class.coerce_and_validate!(raw_args)
120
191
  end
121
192
 
122
193
  # Execute the mutation
194
+ # @param transact_proc [Proc] Block that wraps transactional work (internal use)
123
195
  # @return [Hash] Empty hash on success, or {data: ...} if execute returns a Hash
124
196
  # @raise [ZeroRuby::Error] On failure (formatted at boundary)
125
- def call
197
+ def call(&transact_proc)
198
+ @transact_proc = transact_proc
126
199
  data = execute(**@args)
127
200
  result = {}
128
201
  result[:data] = data if data.is_a?(Hash) && !data.empty?
@@ -131,12 +204,63 @@ module ZeroRuby
131
204
 
132
205
  private
133
206
 
207
+ # Wrap database operations in a transaction with LMID tracking.
208
+ #
209
+ # Behavior depends on skip_auto_transaction:
210
+ # - Default (no skip) - Just executes the block (already in transaction)
211
+ # - skip_auto_transaction - Wraps block in transaction via transact_proc
212
+ #
213
+ # For skip_auto_transaction mutations, you MUST call this method.
214
+ # For default mutations, calling this is optional (no-op, just runs block).
215
+ #
216
+ # @yield Block containing database operations
217
+ # @return [Object] Result of the block
218
+ def transact(&block)
219
+ raise "transact requires a block" unless block_given?
220
+
221
+ if @transact_proc
222
+ # Manual transact mode (auto_transact: false) - use the provided proc
223
+ @transact_proc.call(&block)
224
+ else
225
+ # Auto transact mode - already in transaction, just execute the block
226
+ block.call
227
+ end
228
+ end
229
+
230
+ # Convenience method to access current_user from context.
231
+ # Override in your ApplicationMutation if you need different behavior.
232
+ def current_user
233
+ ctx[:current_user]
234
+ end
235
+
134
236
  # 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(**)
237
+ # Arguments are passed as keyword arguments matching your declared arguments.
238
+ # Access context via ctx[:key] or use the current_user helper.
239
+ #
240
+ # By default (auto_transact: true), the entire execute method runs inside
241
+ # a transaction with LMID tracking. Just write your database operations directly.
242
+ #
243
+ # For 3-phase control, use `skip_auto_transaction` and call transact { ... }:
244
+ # - Pre-transaction: code before transact (auth, validation)
245
+ # - Transaction: code inside transact { } (database operations)
246
+ # - Post-commit: code after transact returns (side effects)
247
+ #
248
+ # @example Simple mutation (default auto_transact: true)
249
+ # def execute(id:, title:)
250
+ # authorize! Post, to: :create?
251
+ # Post.create!(id: id, title: title)
252
+ # end
253
+ #
254
+ # @example 3-phase mutation (skip_auto_transaction)
255
+ # def execute(id:, title:)
256
+ # authorize! Post, to: :create? # Pre-transaction
257
+ # result = transact do
258
+ # Post.create!(id: id, title: title) # Transaction
259
+ # end
260
+ # NotificationService.notify(result.id) # Post-commit
261
+ # {id: result.id}
262
+ # end
263
+ def execute(**args)
140
264
  raise NotImplementedError, "Subclasses must implement #execute"
141
265
  end
142
266
  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
@@ -39,6 +37,10 @@ module ZeroRuby
39
37
  mutations.each_with_index do |mutation_data, index|
40
38
  result = process_mutation_with_lmid(mutation_data, client_group_id, context)
41
39
  results << result
40
+ rescue MutationNotFoundError => e
41
+ # Unknown mutation - return error response and continue batch
42
+ mutation_id_obj = {id: mutation_data["id"], clientID: mutation_data["clientID"]}
43
+ results << {id: mutation_id_obj, result: format_error_response(e)}
42
44
  rescue OutOfOrderMutationError => e
43
45
  # Return top-level PushFailedBody with all unprocessed mutation IDs
44
46
  unprocessed_ids = mutations[index..].map { |m| {id: m["id"], clientID: m["clientID"]} }
@@ -49,7 +51,7 @@ module ZeroRuby
49
51
  message: e.message,
50
52
  mutationIDs: unprocessed_ids
51
53
  }
52
- rescue DatabaseError => e
54
+ rescue DatabaseTransactionError => e
53
55
  # Database errors trigger top-level PushFailed per Zero protocol
54
56
  unprocessed_ids = mutations[index..].map { |m| {id: m["id"], clientID: m["clientID"]} }
55
57
  return {
@@ -66,30 +68,117 @@ module ZeroRuby
66
68
 
67
69
  private
68
70
 
69
- # Process a single mutation with LMID validation and transaction support.
70
- # Uses atomic increment-then-validate pattern matching Zero's TypeScript implementation.
71
+ # Process a single mutation with LMID validation, transaction support, and phase tracking.
72
+ #
73
+ # Supports two execution modes:
74
+ #
75
+ # **Default (auto-transaction)**
76
+ # - Entire execute method is wrapped in a transaction with LMID tracking
77
+ # - User does NOT need to call transact {} - it's automatic
78
+ # - Simple mutations benefit from less boilerplate
79
+ #
80
+ # **skip_auto_transaction**
81
+ # - Uses three-phase model matching Zero's TypeScript implementation:
82
+ # 1. Pre-transaction: User code runs before calling transact (auth, validation)
83
+ # 2. Transaction: User code inside transact { } block (database operations, LMID tracking)
84
+ # 3. Post-commit: User code after transact returns (side effects)
85
+ # - User MUST call transact {} or TransactNotCalledError is raised
86
+ #
87
+ # LMID semantics by phase:
88
+ # - Pre-transaction error: LMID advanced in separate transaction
89
+ # - Transaction error: LMID advanced in separate transaction (original tx rolled back)
90
+ # - Post-commit error: LMID already committed with transaction
71
91
  def process_mutation_with_lmid(mutation_data, client_group_id, context)
72
92
  mutation_id = mutation_data["id"]
73
93
  client_id = mutation_data["clientID"]
94
+ mutation_id_obj = {id: mutation_id, clientID: client_id}
95
+ mutation_name = mutation_data["name"]
74
96
 
75
- mutation_id_obj = {
76
- id: mutation_id,
77
- clientID: client_id
78
- }
97
+ handler_class = schema.handler_for(mutation_name)
98
+ raise MutationNotFoundError.new(mutation_name) unless handler_class
79
99
 
80
- lmid_store.transaction do
81
- # Atomically increment LMID first, then validate.
82
- # This matches the TypeScript implementation's approach for minimal lock duration.
100
+ if handler_class.skip_auto_transaction?
101
+ process_manual_transact(mutation_data, client_group_id, client_id, mutation_id, mutation_id_obj, context)
102
+ else
103
+ process_auto_transact(mutation_data, client_group_id, client_id, mutation_id, mutation_id_obj, context)
104
+ end
105
+ end
106
+
107
+ # Process mutation with auto-transact (entire execute wrapped in transaction)
108
+ def process_auto_transact(mutation_data, client_group_id, client_id, mutation_id, mutation_id_obj, context)
109
+ result = lmid_store.transaction do
83
110
  last_mutation_id = lmid_store.fetch_and_increment(client_group_id, client_id)
84
111
  check_lmid!(client_id, mutation_id, last_mutation_id)
85
-
86
- result = execute_with_retry(mutation_data, context)
87
- {id: mutation_id_obj, result: result}
112
+ schema.execute_mutation(mutation_data, context)
88
113
  end
89
- rescue OutOfOrderMutationError, DatabaseError
114
+ {id: mutation_id_obj, result: result}
115
+ rescue MutationAlreadyProcessedError => e
116
+ {id: mutation_id_obj, result: format_error_response(e)}
117
+ rescue OutOfOrderMutationError, DatabaseTransactionError
90
118
  raise
91
- rescue ZeroRuby::Error => e
119
+ rescue ActiveRecord::StatementInvalid => e
120
+ raise DatabaseTransactionError.new("Transaction failed: #{e.message}")
121
+ rescue ZeroRuby::Error, FloatDomainError => e
122
+ # No retry - matches TypeScript behavior (TS does NOT retry user code)
123
+ # Transaction rolled back - advance LMID separately to prevent replay
124
+ persist_lmid_on_application_error(client_group_id, client_id)
125
+ error = e.is_a?(FloatDomainError) ?
126
+ ValidationError.new(["Invalid numeric value: #{e.message}"]) : e
127
+ {id: mutation_id_obj, result: format_error_response(error)}
128
+ rescue => e
129
+ raise DatabaseTransactionError.new("Transaction failed: #{e.message}")
130
+ end
131
+
132
+ # Process mutation with manual transact (3-phase model)
133
+ # No retry - matches TypeScript behavior (TS does NOT retry user code)
134
+ def process_manual_transact(mutation_data, client_group_id, client_id, mutation_id, mutation_id_obj, context)
135
+ phase = :pre_transaction
136
+ transact_called = false
137
+
138
+ transact_proc = proc { |&user_block|
139
+ transact_called = true
140
+ phase = :transaction
141
+ result = lmid_store.transaction do
142
+ last_mutation_id = lmid_store.fetch_and_increment(client_group_id, client_id)
143
+ check_lmid!(client_id, mutation_id, last_mutation_id)
144
+ user_block.call
145
+ end
146
+ phase = :post_commit
147
+ result
148
+ }
149
+
150
+ result = schema.execute_mutation(mutation_data, context, &transact_proc)
151
+ raise TransactNotCalledError.new unless transact_called
152
+ {id: mutation_id_obj, result: result}
153
+ rescue MutationAlreadyProcessedError => e
92
154
  {id: mutation_id_obj, result: format_error_response(e)}
155
+ rescue OutOfOrderMutationError, DatabaseTransactionError
156
+ raise
157
+ rescue ActiveRecord::StatementInvalid => e
158
+ raise DatabaseTransactionError.new("Transaction failed: #{e.message}")
159
+ rescue ZeroRuby::Error, FloatDomainError => e
160
+ # LMID semantics by phase (no retry - matches TS):
161
+ # - Pre-transaction: LMID advanced separately
162
+ # - Transaction (rolled back): LMID advanced separately
163
+ # - Post-commit: LMID already committed with transaction
164
+ if phase != :post_commit
165
+ persist_lmid_on_application_error(client_group_id, client_id)
166
+ end
167
+ error = e.is_a?(FloatDomainError) ?
168
+ ValidationError.new(["Invalid numeric value: #{e.message}"]) : e
169
+ {id: mutation_id_obj, result: format_error_response(error)}
170
+ rescue => e
171
+ raise DatabaseTransactionError.new("Transaction failed: #{e.message}")
172
+ end
173
+
174
+ # Persist LMID advancement after an application error.
175
+ # Called for pre-transaction and transaction errors to prevent replay attacks.
176
+ def persist_lmid_on_application_error(client_group_id, client_id)
177
+ lmid_store.transaction do
178
+ lmid_store.fetch_and_increment(client_group_id, client_id)
179
+ end
180
+ rescue => e
181
+ warn "Failed to persist LMID after application error: #{e.message}"
93
182
  end
94
183
 
95
184
  # Validate LMID against the post-increment value.
@@ -113,21 +202,6 @@ module ZeroRuby
113
202
  end
114
203
  end
115
204
 
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
205
  # Format an error into Zero protocol response
132
206
  def format_error_response(error)
133
207
  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,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "types"
4
+
3
5
  module ZeroRuby
4
6
  # Provides shorthand constants for ZeroRuby types.
5
7
  # Include this module to use ID, Boolean, etc. without the ZeroRuby::Types:: prefix.
6
8
  #
7
- # This is automatically included in Mutation and InputObject, so you can write:
8
- #
9
+ # @example Including in your own classes
9
10
  # class PostCreate < ZeroRuby::Mutation
10
- # argument :id, ID, required: true
11
- # argument :title, String, required: true
12
- # argument :active, Boolean, required: true
11
+ # include ZeroRuby::TypeNames
12
+ #
13
+ # argument :id, ID
14
+ # argument :title, Types::String
15
+ # argument :active, Boolean
13
16
  # end
14
17
  #
15
- # Note: String, Integer, and Float work automatically because Ruby's built-in
16
- # classes are resolved to ZeroRuby types by the argument system.
18
+ # Note: Ruby's built-in String, Integer, and Float cannot be aliased since
19
+ # they are actual classes. Use ZeroRuby::Types::String, etc. instead.
17
20
  module TypeNames
18
21
  ID = ZeroRuby::Types::ID
19
22
  Boolean = ZeroRuby::Types::Boolean
20
- BigInt = ZeroRuby::Types::BigInt
21
23
  ISO8601Date = ZeroRuby::Types::ISO8601Date
22
24
  ISO8601DateTime = ZeroRuby::Types::ISO8601DateTime
25
+
26
+ # Provide Types module access without prefix
27
+ Types = ZeroRuby::Types
23
28
  end
24
29
  end