zero_ruby 0.1.0.alpha1 → 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 +141 -90
  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 +24 -0
  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 +206 -80
  9. data/lib/zero_ruby/push_processor.rb +134 -37
  10. data/lib/zero_ruby/schema.rb +64 -16
  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 +48 -22
  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
@@ -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
@@ -36,12 +34,33 @@ module ZeroRuby
36
34
  mutations = push_data["mutations"] || []
37
35
  results = []
38
36
 
39
- mutations.each do |mutation_data|
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
42
-
43
- # If we hit an out-of-order error, stop processing the batch
44
- break if result[:result][:error] == "ooo"
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)}
44
+ rescue OutOfOrderMutationError => e
45
+ # Return top-level PushFailedBody with all unprocessed mutation IDs
46
+ unprocessed_ids = mutations[index..].map { |m| {id: m["id"], clientID: m["clientID"]} }
47
+ return {
48
+ kind: "PushFailed",
49
+ origin: "server",
50
+ reason: "oooMutation",
51
+ message: e.message,
52
+ mutationIDs: unprocessed_ids
53
+ }
54
+ rescue DatabaseTransactionError => e
55
+ # Database errors trigger top-level PushFailed per Zero protocol
56
+ unprocessed_ids = mutations[index..].map { |m| {id: m["id"], clientID: m["clientID"]} }
57
+ return {
58
+ kind: "PushFailed",
59
+ origin: "server",
60
+ reason: "database",
61
+ message: e.message,
62
+ mutationIDs: unprocessed_ids
63
+ }
45
64
  end
46
65
 
47
66
  {mutations: results}
@@ -49,28 +68,117 @@ module ZeroRuby
49
68
 
50
69
  private
51
70
 
52
- # Process a single mutation with LMID validation and transaction support.
53
- # 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
54
91
  def process_mutation_with_lmid(mutation_data, client_group_id, context)
55
92
  mutation_id = mutation_data["id"]
56
93
  client_id = mutation_data["clientID"]
94
+ mutation_id_obj = {id: mutation_id, clientID: client_id}
95
+ mutation_name = mutation_data["name"]
57
96
 
58
- mutation_id_obj = {
59
- id: mutation_id,
60
- clientID: client_id
61
- }
97
+ handler_class = schema.handler_for(mutation_name)
98
+ raise MutationNotFoundError.new(mutation_name) unless handler_class
62
99
 
63
- lmid_store.transaction do
64
- # Atomically increment LMID first, then validate.
65
- # 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
66
110
  last_mutation_id = lmid_store.fetch_and_increment(client_group_id, client_id)
67
111
  check_lmid!(client_id, mutation_id, last_mutation_id)
68
-
69
- result = execute_with_retry(mutation_data, context)
70
- {id: mutation_id_obj, result: result}
112
+ schema.execute_mutation(mutation_data, context)
71
113
  end
72
- rescue ZeroRuby::Error => e
114
+ {id: mutation_id_obj, result: result}
115
+ rescue MutationAlreadyProcessedError => e
73
116
  {id: mutation_id_obj, result: format_error_response(e)}
117
+ rescue OutOfOrderMutationError, DatabaseTransactionError
118
+ raise
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
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}"
74
182
  end
75
183
 
76
184
  # Validate LMID against the post-increment value.
@@ -94,29 +202,18 @@ module ZeroRuby
94
202
  end
95
203
  end
96
204
 
97
- # Execute mutation with retry logic for app errors
98
- def execute_with_retry(mutation_data, context)
99
- attempts = 0
100
-
101
- loop do
102
- attempts += 1
103
-
104
- begin
105
- return schema.execute_mutation(mutation_data, context)
106
- rescue ZeroRuby::Error => e
107
- raise e unless attempts < max_retries
108
- end
109
- end
110
- end
111
-
112
205
  # Format an error into Zero protocol response
113
206
  def format_error_response(error)
114
- result = {error: error.error_type, message: error.message}
207
+ result = {error: error.error_type}
115
208
 
116
209
  case error
117
210
  when ValidationError
211
+ result[:message] = error.message
118
212
  result[:details] = {messages: error.errors}
213
+ when MutationAlreadyProcessedError
214
+ result[:details] = error.message
119
215
  else
216
+ result[:message] = error.message
120
217
  result[:details] = error.details if error.details
121
218
  end
122
219
 
@@ -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
@@ -46,50 +54,86 @@ module ZeroRuby
46
54
  # result = ZeroSchema.execute(body, context: {current_user: user})
47
55
  # render json: result
48
56
  def execute(push_data, context:, lmid_store: nil)
57
+ validate_push_structure!(push_data)
58
+
49
59
  push_version = push_data["pushVersion"]
50
60
  supported_version = ZeroRuby.configuration.supported_push_version
51
61
 
52
62
  unless push_version == supported_version
63
+ mutations = push_data["mutations"] || []
64
+ mutation_ids = mutations.map { |m| {id: m["id"], clientID: m["clientID"]} }
65
+
53
66
  return {
54
- error: {
55
- kind: "PushFailed",
56
- reason: "UnsupportedPushVersion",
57
- message: "Unsupported push version: #{push_version}. Expected: #{supported_version}"
58
- }
67
+ kind: "PushFailed",
68
+ origin: "server",
69
+ reason: "unsupportedPushVersion",
70
+ message: "Unsupported push version: #{push_version}. Expected: #{supported_version}",
71
+ mutationIDs: mutation_ids
59
72
  }
60
73
  end
61
74
 
62
75
  store = lmid_store || ZeroRuby.configuration.lmid_store_instance
63
76
  processor = PushProcessor.new(
64
77
  schema: self,
65
- lmid_store: store,
66
- max_retries: ZeroRuby.configuration.max_retry_attempts
78
+ lmid_store: store
67
79
  )
68
80
  processor.process(push_data, context)
81
+ rescue ParseError => e
82
+ {
83
+ kind: "PushFailed",
84
+ origin: "server",
85
+ reason: "parse",
86
+ message: e.message,
87
+ mutationIDs: []
88
+ }
69
89
  end
70
90
 
71
91
  # Execute a single mutation.
72
92
  # Used by PushProcessor for LMID-tracked mutations.
73
93
  # @param mutation_data [Hash] The mutation data from Zero
74
94
  # @param context [Hash] Context hash to pass to mutations
95
+ # @param transact [Proc] Block that wraps transactional work
75
96
  # @return [Hash] Empty hash on success
76
97
  # @raise [MutationNotFoundError] If the mutation is not registered
77
98
  # @raise [ZeroRuby::Error] If the mutation fails
78
- def execute_mutation(mutation_data, context)
99
+ def execute_mutation(mutation_data, context, &transact)
79
100
  name = normalize_mutation_name(mutation_data["name"])
80
- raw_args = extract_args(mutation_data)
81
- params = transform_keys(raw_args)
82
-
83
- ctx = context.freeze
84
101
  handler = mutations[name]
85
-
86
102
  raise MutationNotFoundError.new(name) unless handler
87
103
 
88
- 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)])
89
114
  end
90
115
 
91
116
  private
92
117
 
118
+ # Validate push data structure per Zero protocol
119
+ # Required fields: clientGroupID, mutations, pushVersion, timestamp, requestID
120
+ # @raise [ParseError] If push data is malformed
121
+ def validate_push_structure!(push_data)
122
+ unless push_data.is_a?(Hash)
123
+ raise ParseError.new("Push data must be a hash")
124
+ end
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")
134
+ end
135
+ end
136
+
93
137
  # Normalize mutation name (convert | to . for Zero's format)
94
138
  def normalize_mutation_name(name)
95
139
  return "" if name.nil?
@@ -105,12 +149,16 @@ module ZeroRuby
105
149
  args.is_a?(Array) ? (args.first || {}) : args
106
150
  end
107
151
 
108
- # 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
109
157
  def transform_keys(object)
110
158
  case object
111
159
  when Hash
112
160
  object.each_with_object({}) do |(key, value), result|
113
- 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("_")
114
162
  result[new_key] = transform_keys(value)
115
163
  end
116
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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-types"
4
+
5
+ module ZeroRuby
6
+ # Type definitions using dry-types.
7
+ #
8
+ # When inheriting from ZeroRuby::Mutation or ZeroRuby::InputObject, types are
9
+ # available via ZeroRuby::TypeNames which is automatically included:
10
+ # - Direct: ID, Boolean, ISO8601Date, ISO8601DateTime
11
+ # - Via Types: Types::String, Types::Integer, Types::Float
12
+ #
13
+ # @example Basic usage
14
+ # class MyMutation < ZeroRuby::Mutation
15
+ # argument :id, ID
16
+ # argument :name, Types::String
17
+ # argument :count, Types::Integer.optional
18
+ # argument :active, Boolean.default(false)
19
+ # end
20
+ #
21
+ # @example With constraints
22
+ # class MyMutation < ZeroRuby::Mutation
23
+ # argument :title, Types::String.constrained(min_size: 1, max_size: 200)
24
+ # argument :count, Types::Integer.constrained(gt: 0)
25
+ # argument :status, Types::String.constrained(included_in: %w[draft published])
26
+ # end
27
+ module Types
28
+ include Dry.Types()
29
+
30
+ # Params types for JSON input
31
+ # These handle string coercions common in form/JSON data
32
+
33
+ # String type (passes through strings, coerces nil)
34
+ String = Params::String
35
+
36
+ # Coerces string numbers to integers (e.g., "42" -> 42)
37
+ Integer = Params::Integer
38
+
39
+ # Coerces string numbers to floats (e.g., "3.14" -> 3.14)
40
+ Float = Params::Float
41
+
42
+ # Coerces string booleans (e.g., "true" -> true, "false" -> false)
43
+ Boolean = Params::Bool
44
+
45
+ # Non-empty string ID type
46
+ ID = Params::String.constrained(filled: true)
47
+
48
+ # ISO8601 date string -> Date object
49
+ ISO8601Date = Params::Date
50
+
51
+ # ISO8601 datetime string -> DateTime object
52
+ ISO8601DateTime = Params::DateTime
53
+ end
54
+ end