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.
- checksums.yaml +4 -4
- data/README.md +124 -70
- data/lib/zero_ruby/configuration.rb +0 -5
- data/lib/zero_ruby/error_formatter.rb +171 -0
- data/lib/zero_ruby/errors.rb +10 -1
- data/lib/zero_ruby/input_object.rb +56 -93
- data/lib/zero_ruby/lmid_stores/active_record_store.rb +0 -1
- data/lib/zero_ruby/mutation.rb +201 -77
- data/lib/zero_ruby/push_processor.rb +108 -34
- data/lib/zero_ruby/schema.rb +38 -14
- data/lib/zero_ruby/type_names.rb +13 -8
- data/lib/zero_ruby/types.rb +54 -0
- data/lib/zero_ruby/typescript_generator.rb +126 -58
- data/lib/zero_ruby/version.rb +1 -1
- data/lib/zero_ruby.rb +11 -34
- 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 "validator"
|
|
6
6
|
|
|
7
7
|
module ZeroRuby
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 [
|
|
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,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
|
-
# @
|
|
47
|
-
# @
|
|
48
|
-
# @raise [
|
|
49
|
-
|
|
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,
|
|
54
|
-
|
|
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
|
-
#
|
|
57
|
-
if
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
#
|
|
77
|
-
if
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
136
|
-
# Access context via ctx[:key]
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
70
|
-
#
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
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,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
|
-
#
|
|
8
|
-
#
|
|
9
|
+
# @example Including in your own classes
|
|
9
10
|
# class PostCreate < ZeroRuby::Mutation
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# argument :
|
|
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
|
|
16
|
-
#
|
|
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
|