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.
- checksums.yaml +4 -4
- data/README.md +120 -71
- data/lib/zero_ruby/configuration.rb +0 -5
- data/lib/zero_ruby/error_formatter.rb +173 -0
- data/lib/zero_ruby/errors.rb +10 -1
- data/lib/zero_ruby/input_object.rb +55 -93
- data/lib/zero_ruby/lmid_stores/active_record_store.rb +0 -1
- data/lib/zero_ruby/mutation.rb +235 -80
- data/lib/zero_ruby/push_processor.rb +54 -35
- data/lib/zero_ruby/schema.rb +38 -14
- data/lib/zero_ruby/type_names.rb +7 -15
- data/lib/zero_ruby/types.rb +52 -0
- data/lib/zero_ruby/typescript_generator.rb +167 -57
- data/lib/zero_ruby/version.rb +1 -1
- data/lib/zero_ruby.rb +12 -35
- metadata +46 -20
- data/lib/zero_ruby/argument.rb +0 -75
- data/lib/zero_ruby/types/base_type.rb +0 -54
- data/lib/zero_ruby/types/big_int.rb +0 -32
- data/lib/zero_ruby/types/boolean.rb +0 -30
- data/lib/zero_ruby/types/float.rb +0 -31
- data/lib/zero_ruby/types/id.rb +0 -33
- data/lib/zero_ruby/types/integer.rb +0 -31
- data/lib/zero_ruby/types/iso8601_date.rb +0 -43
- data/lib/zero_ruby/types/iso8601_date_time.rb +0 -43
- data/lib/zero_ruby/types/string.rb +0 -20
- data/lib/zero_ruby/validator.rb +0 -69
- data/lib/zero_ruby/validators/allow_blank_validator.rb +0 -31
- data/lib/zero_ruby/validators/allow_null_validator.rb +0 -26
- data/lib/zero_ruby/validators/exclusion_validator.rb +0 -29
- data/lib/zero_ruby/validators/format_validator.rb +0 -35
- data/lib/zero_ruby/validators/inclusion_validator.rb +0 -30
- data/lib/zero_ruby/validators/length_validator.rb +0 -42
- data/lib/zero_ruby/validators/numericality_validator.rb +0 -63
data/lib/zero_ruby/mutation.rb
CHANGED
|
@@ -1,38 +1,88 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "types"
|
|
4
|
+
require_relative "type_names"
|
|
4
5
|
require_relative "errors"
|
|
5
|
-
require_relative "
|
|
6
|
+
require_relative "error_formatter"
|
|
6
7
|
|
|
7
8
|
module ZeroRuby
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 [
|
|
19
|
-
# @param
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
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,
|
|
54
|
-
|
|
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
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
136
|
-
# Access context via ctx[:key]
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
70
|
-
#
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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}
|
data/lib/zero_ruby/schema.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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("_")
|
|
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
|
data/lib/zero_ruby/type_names.rb
CHANGED
|
@@ -1,24 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ZeroRuby
|
|
4
|
-
# Provides
|
|
5
|
-
#
|
|
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
|
|
11
|
-
# argument :title, String
|
|
12
|
-
# argument :active, Boolean
|
|
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
|
-
|
|
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
|