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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba93bb14b442ea97c4ac597b0c97e7b9e0ec00a334e5f2fd6bb3ee7639d47302
4
- data.tar.gz: 741f8f22deb20a49cf7ffd75a03657faf28d561ad32a03debb74f4f5b4e41de8
3
+ metadata.gz: 8d78f7995f5a44093ed0202e53dd345587f73a2d2a675e31c158fe01ed392e6e
4
+ data.tar.gz: 9fbbb4cdce370fca8c035e0fad0cd0dbbeaa9ace5cf92061cdab6274223987ae
5
5
  SHA512:
6
- metadata.gz: bab7af54647503b8322eef911336dd9abfe952eaa574baa7f61da57e72e234754a03287354f24af6022b15731097c43a51b5ba67ecb441d0dcc148de834b7b2d
7
- data.tar.gz: 9a4015f062da3df3cdfbc32fc00f747046dee0a30d03228995b59049b9ce2a44c0df10836db173f906ceb8aebd76d935a116576d74848187c88470b7aeb3c4bd
6
+ metadata.gz: 6b4e963190e7911e4b9b137933165bd20eebabf2e9a183f9ae7404e0f3ab6b00c2f321098fdf58aaf2ab84fbca7490e1e82b47bb5ed2e3616aa10151173ba2ba
7
+ data.tar.gz: 35b53c46c18655067e7e393893539fa451baf956a3f3a827cd9edfd340e33b29eb55ef9327297ae279a3604717244f3d04cf5ddb2bba5f28308b200c6f724b19
data/README.md CHANGED
@@ -2,13 +2,10 @@
2
2
 
3
3
  A Ruby gem for handling [Zero](https://zero.rocicorp.dev/) mutations with type safety, validation, and full protocol support.
4
4
 
5
- 0.1.0.alpha1
6
-
7
5
  ## Features
8
6
 
9
- - **Type coercion & checking** - String, Integer, Float, Boolean, ID, BigInt, ISO8601Date, ISO8601DateTime with automatic conversion and runtime type validation
10
- - **Type generation** - Generates typescript types you can use for your frontend mutators
11
- - **Argument validation** - length, numericality, format, inclusion, exclusion, etc.
7
+ - **Type coercion & validation** - Built on [dry-types](https://dry-rb.org/gems/dry-types/) with String, Integer, Float, Boolean, ID, ISO8601Date, ISO8601DateTime
8
+ - **Type generation** - Generates TypeScript types for your frontend mutators
12
9
  - **LMID tracking** - Duplicate and out-of-order mutation detection using Zero's `zero_0.clients` table
13
10
  - **Push protocol** - Version validation, transaction wrapping, retry logic
14
11
 
@@ -22,58 +19,27 @@ gem 'zero_ruby'
22
19
 
23
20
  ## Usage
24
21
 
25
- ### 1. Base classes (optional)
26
-
27
- Create base classes to share behavior across mutations and input types:
28
-
29
- ```ruby
30
- # app/zero/types/base_input_object.rb
31
- module Types
32
- class BaseInputObject < ZeroRuby::InputObject
33
- # Add shared behavior across all input objects here
34
- end
35
- end
36
-
37
- # app/zero/mutations/application_mutation.rb
38
- class ApplicationMutation < ZeroRuby::Mutation
39
- def current_user
40
- ctx[:current_user]
41
- end
42
- end
43
- ```
44
-
45
- ### 2. Define custom input types (optional)
46
-
47
- ```ruby
48
- # app/zero/types/post_input.rb
49
- module Types
50
- class PostInput < Types::BaseInputObject
51
- argument :title, String, required: true,
52
- validates: { length: { minimum: 1, maximum: 200 } }
53
- argument :body, String, required: false
54
- argument :published, Boolean, required: false, default: false
55
- end
56
- end
57
- ```
22
+ ### 1. Define mutations
58
23
 
59
- ### 3. Define mutations
24
+ By default, the entire `execute` method runs inside a transaction with LMID (Last Mutation ID) tracking. Use [`skip_auto_transaction`](#manual-transaction-control) to manually control what runs inside the LMID transaction.
60
25
 
61
26
  ```ruby
62
27
  # app/zero/mutations/post_update.rb
63
28
  module Mutations
64
29
  class PostUpdate < ApplicationMutation
65
- argument :id, ID, required: true
66
- argument :post_input, Types::PostInput, required: true
30
+ argument :id, ID
31
+ argument :post_input, Types::PostInput
67
32
 
68
33
  def execute(id:, post_input:)
69
34
  post = current_user.posts.find(id)
35
+ authorize! post, to: :update?
70
36
  post.update!(**post_input)
71
37
  end
72
38
  end
73
39
  end
74
40
  ```
75
41
 
76
- ### 4. Register mutations in schema
42
+ ### 2. Register mutations in schema
77
43
 
78
44
  ```ruby
79
45
  # app/zero/app_schema.rb
@@ -85,7 +51,7 @@ class ZeroSchema < ZeroRuby::Schema
85
51
  end
86
52
  ```
87
53
 
88
- ### 5. Add controller
54
+ ### 3. Add zero_controller and route
89
55
 
90
56
  ```ruby
91
57
  # app/controllers/zero_controller.rb
@@ -112,23 +78,34 @@ class ZeroController < ApplicationController
112
78
  end
113
79
  rescue JSON::ParserError => e
114
80
  render json: {
115
- error: {
116
- kind: "PushFailed",
117
- reason: "Parse",
118
- message: "Invalid JSON: #{e.message}"
119
- }
81
+ kind: "PushFailed",
82
+ origin: "server",
83
+ reason: "parse",
84
+ message: "Invalid JSON: #{e.message}",
85
+ mutationIDs: []
120
86
  }, status: :bad_request
121
87
  end
122
88
  end
123
89
  ```
124
90
 
125
- ### 6. Add route
126
-
127
91
  ```ruby
128
92
  # config/routes.rb
129
93
  match '/zero/push', to: 'zero#push', via: [:get, :post]
130
94
  ```
131
95
 
96
+ ## Define custom input types (optional)
97
+
98
+ ```ruby
99
+ # app/zero/types/post_input.rb
100
+ module Types
101
+ class PostInput < Types::BaseInputObject
102
+ argument :title, Types::String.constrained(min_size: 1, max_size: 200)
103
+ argument :body, Types::String.optional
104
+ argument :published, Boolean.default(false)
105
+ end
106
+ end
107
+ ```
108
+
132
109
  ## Configuration
133
110
 
134
111
  Create an initializer to customize settings (all options have sensible defaults):
@@ -139,8 +116,8 @@ ZeroRuby.configure do |config|
139
116
  # Storage backend (:active_record is the only built-in option)
140
117
  config.lmid_store = :active_record
141
118
 
142
- # Retry attempts for transient errors
143
- config.max_retry_attempts = 3
119
+ # Retry attempts for transient errors (default: 1)
120
+ config.max_retry_attempts = 1
144
121
 
145
122
  # Push protocol version (reject requests with different version)
146
123
  config.supported_push_version = 1
@@ -165,7 +142,7 @@ ZeroRuby generates TypeScript type definitions from your Ruby mutations. GET req
165
142
  }
166
143
  ```
167
144
 
168
- ### Using with Zero Mutators
145
+ ### Use with Zero Mutators
169
146
 
170
147
  ```typescript
171
148
  import { defineMutator, defineMutators } from '@rocicorp/zero'
@@ -179,7 +156,7 @@ export const mutators = defineMutators({
179
156
  update: defineMutator(postsUpdateArgsSchema, async ({ tx, args }) => {
180
157
  await tx.mutate.posts.update({
181
158
  id: args.id,
182
- ...(args.postInput.title !== undefined && { title: args.postInput.title }),
159
+ title: args.postInput.title,
183
160
  updatedAt: Date.now(),
184
161
  })
185
162
  }),
@@ -189,57 +166,131 @@ export const mutators = defineMutators({
189
166
  export type Mutators = typeof mutators
190
167
  ```
191
168
 
192
- ## Validation
169
+ ## Types
170
+
171
+ ZeroRuby provides types built on [dry-types](https://dry-rb.org/gems/dry-types/). When you inherit from `ZeroRuby::Mutation` or `ZeroRuby::InputObject`, the following are available:
193
172
 
194
- Built-in validators:
173
+ - **Direct constants**: `ID`, `Boolean`, `ISO8601Date`, `ISO8601DateTime`
174
+ - **Via Types module**: `Types::String`, `Types::Integer`, `Types::Float`
175
+
176
+ (Note: `String`, `Integer`, `Float` can't be direct constants because they conflict with Ruby's built-in classes)
195
177
 
196
178
  ```ruby
197
- argument :name, String, required: true,
198
- validates: {
199
- length: { minimum: 1, maximum: 100 },
200
- format: { with: /\A[a-z]+\z/i, message: "only letters allowed" },
201
- allow_blank: false
202
- }
179
+ # Basic types
180
+ argument :name, Types::String
181
+ argument :count, Types::Integer
182
+ argument :price, Types::Float
183
+ argument :active, Boolean
184
+ argument :id, ID # Non-empty string
185
+ argument :date, ISO8601Date
186
+ argument :timestamp, ISO8601DateTime
187
+
188
+ # Optional types (accepts nil)
189
+ argument :nickname, Types::String.optional
190
+
191
+ # Default values
192
+ argument :status, Types::String.default("draft")
193
+ argument :enabled, Boolean.default(false)
194
+ ```
203
195
 
204
- argument :age, Integer, required: true,
205
- validates: {
206
- numericality: { greater_than: 0, less_than: 150 }
207
- }
196
+ ## Validation with Constraints
208
197
 
209
- argument :status, String, required: true,
210
- validates: {
211
- inclusion: { in: %w[draft published archived] }
212
- }
198
+ Use dry-types constraints for validation:
213
199
 
214
- argument :username, String, required: true,
215
- validates: {
216
- exclusion: { in: %w[admin root system], message: "is reserved" }
217
- }
200
+ ```ruby
201
+ # Length constraints
202
+ argument :title, Types::String.constrained(min_size: 1, max_size: 200)
203
+ argument :code, Types::String.constrained(size: 6) # Exact size
218
204
 
219
- argument :email, String, required: true,
220
- validates: {
221
- allow_null: false,
222
- allow_blank: false
223
- }
205
+ # Numeric constraints
206
+ argument :age, Types::Integer.constrained(gt: 0, lt: 150)
207
+ argument :quantity, Types::Integer.constrained(gteq: 1, lteq: 100)
208
+
209
+ # Format (regex)
210
+ argument :slug, Types::String.constrained(format: /\A[a-z0-9-]+\z/)
211
+
212
+ # Inclusion
213
+ argument :status, Types::String.constrained(included_in: %w[draft published archived])
214
+
215
+ # Exclusion
216
+ argument :username, Types::String.constrained(excluded_from: %w[admin root system])
217
+
218
+ # Non-empty (filled)
219
+ argument :email, Types::String.constrained(filled: true)
220
+
221
+ # Combine constraints
222
+ argument :name, Types::String.constrained(min_size: 1, max_size: 100, format: /\A[a-zA-Z ]+\z/)
224
223
  ```
225
224
 
226
- ## Type coercion & checking
225
+ ### Available Constraints
226
+
227
+ | Constraint | Description | Example |
228
+ |------------|-------------|---------|
229
+ | `min_size` | Minimum length | `min_size: 1` |
230
+ | `max_size` | Maximum length | `max_size: 200` |
231
+ | `size` | Exact length | `size: 6` |
232
+ | `gt` | Greater than | `gt: 0` |
233
+ | `gteq` | Greater than or equal | `gteq: 1` |
234
+ | `lt` | Less than | `lt: 100` |
235
+ | `lteq` | Less than or equal | `lteq: 99` |
236
+ | `format` | Regex pattern | `format: /\A\d+\z/` |
237
+ | `included_in` | Value must be in list | `included_in: %w[a b c]` |
238
+ | `excluded_from` | Value must not be in list | `excluded_from: %w[x y]` |
239
+ | `filled` | Non-empty string | `filled: true` |
227
240
 
228
- Types automatically coerce compatible values and raise `CoercionError` for invalid input:
241
+ ## Type coercion
242
+
243
+ Types automatically coerce compatible values:
229
244
 
230
245
  | Type | Accepts | Rejects |
231
246
  |------|---------|---------|
232
- | `String` | Any value (via `.to_s`) | - |
233
- | `Integer` | `42`, `"42"`, `3.7` → `3` | `"abc"`, `""`, arrays, hashes |
234
- | `Float` | `3.14`, `"3.14"`, `42` → `42.0` | `"abc"`, `""`, arrays, hashes |
235
- | `Boolean` | `true`, `false`, `"true"`, `"false"`, `0`, `1` | `"yes"`, `"maybe"`, other values |
236
- | `ID` | `"abc"`, `123` `"123"`, `:sym` `"sym"` | `""`, arrays, hashes |
237
- | `BigInt` | `123`, `"9007199254740993"` | `"abc"`, `""`, floats |
238
- | `ISO8601Date` | `"2025-01-15"`, `Date`, `Time` → `Date` | `"invalid"`, `""`, integers |
239
- | `ISO8601DateTime` | `"2025-01-15T10:30:00Z"`, `Time`, `DateTime` | `"invalid"`, `""`, integers |
247
+ | `String` | `"hello"` | `nil` |
248
+ | `Integer` | `42`, `"42"`, `3.7` → `3` | `"abc"`, `""` |
249
+ | `Float` | `3.14`, `"3.14"`, `42` → `42.0` | `"abc"`, `""` |
250
+ | `Boolean` | `true`, `false`, `"true"`, `"false"` | `"yes"`, `1`, `0` |
251
+ | `ID` | `"abc"` | `""` (empty string) |
252
+ | `ISO8601Date` | `"2025-01-15"` → `Date` | `"invalid"`, `""` |
253
+ | `ISO8601DateTime` | `"2025-01-15T10:30:00Z"` → `DateTime` | `"invalid"`, `""` |
254
+
255
+ ## Manual transaction control
256
+
257
+ By default, the entire `execute` method runs inside a transaction in order to atomically commit database changes with the LMID update. Use `skip_auto_transaction` when you need to run code before or after the transaction:
258
+
259
+ ```ruby
260
+ class PostUpdate < ApplicationMutation
261
+ skip_auto_transaction
262
+
263
+ argument :id, ID
264
+ argument :post_input, Types::PostInput
265
+
266
+ def execute(id:, post_input:)
267
+ # 1. Pre-transaction (LMID incremented on error)
268
+ post = current_user.posts.find(id)
269
+ authorize! post, to: :update?
270
+
271
+ # 2. Transaction (Transaction rolled back, LMID incremented on error)
272
+ transact do
273
+ post.update!(**post_input)
274
+ end
275
+
276
+ # 3. Post-commit - only runs if transact succeeded
277
+ NotificationService.notify_update(id)
278
+ end
279
+ end
280
+ ```
281
+
282
+ With `skip_auto_transaction`, you **must** call `transact { }` or `TransactNotCalledError` is raised.
283
+
284
+ ### LMID behavior by phase
285
+
286
+ | Phase | On Error |
287
+ |-------|----------|
288
+ | Pre-transaction | LMID advanced in separate transaction |
289
+ | Transaction | LMID advanced in separate transaction (original tx rolled back) |
290
+ | Post-commit | LMID already committed with transaction |
240
291
 
241
292
  ## References
242
293
 
243
294
  - [Zero Documentation](https://zero.rocicorp.dev/docs/mutators)
244
295
  - [Zero Server Implementation](https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts)
245
- - Inspired by [graphql-ruby](https://github.com/rmosolgo/graphql-ruby)
296
+ - [dry-types Documentation](https://dry-rb.org/gems/dry-types/)
@@ -6,22 +6,17 @@ module ZeroRuby
6
6
  # @example
7
7
  # ZeroRuby.configure do |config|
8
8
  # config.lmid_store = :active_record
9
- # config.max_retry_attempts = 3
10
9
  # end
11
10
  class Configuration
12
11
  # LMID (Last Mutation ID) tracking settings
13
12
  # The LMID store backend: :active_record or a custom LmidStore instance
14
13
  attr_accessor :lmid_store
15
14
 
16
- # Maximum retry attempts for ApplicationError during mutation processing
17
- attr_accessor :max_retry_attempts
18
-
19
15
  # The push version supported by this configuration
20
16
  attr_accessor :supported_push_version
21
17
 
22
18
  def initialize
23
19
  @lmid_store = :active_record
24
- @max_retry_attempts = 3
25
20
  @supported_push_version = 1
26
21
  end
27
22
 
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema"
4
+
5
+ module ZeroRuby
6
+ # Formats Dry::Types and Dry::Struct errors into user-friendly messages
7
+ # using dry-schema's built-in message templates.
8
+ module ErrorFormatter
9
+ # Get the dry-schema messages backend for English
10
+ MESSAGES = Dry::Schema::Messages::YAML.build
11
+
12
+ class << self
13
+ # Format a Dry::Struct::Error into user-friendly messages
14
+ # @param error [Dry::Struct::Error] The struct error
15
+ # @return [Array<String>] Formatted error messages
16
+ def format_struct_error(error)
17
+ message = error.message
18
+
19
+ if message.include?("is missing")
20
+ match = message.match(/:(\w+) is missing/)
21
+ field = match ? match[1] : "field"
22
+ ["#{field} is required"]
23
+ elsif message.include?("has invalid type")
24
+ match = message.match(/:(\w+) has invalid type/)
25
+ field = match ? match[1] : "field"
26
+ ["#{field}: invalid type"]
27
+ elsif message.include?("violates constraints")
28
+ # Extract constraint info and format it
29
+ [format_constraint_message(message)]
30
+ else
31
+ [message]
32
+ end
33
+ end
34
+
35
+ # Format a Dry::Types::CoercionError into user-friendly message
36
+ # @param error [Dry::Types::CoercionError] The coercion error
37
+ # @return [String] Formatted error message
38
+ def format_coercion_error(error)
39
+ message = error.message
40
+ input = error.respond_to?(:input) ? error.input : nil
41
+
42
+ type = extract_type_name(message)
43
+ if input
44
+ value_str = format_value(input)
45
+ "#{value_str} is not a valid #{type}"
46
+ else
47
+ lookup_message(:type?, type) || "must be a #{type}"
48
+ end
49
+ end
50
+
51
+ # Format a Dry::Types::ConstraintError into user-friendly message
52
+ # @param error [Dry::Types::ConstraintError] The constraint error
53
+ # @return [String] Formatted error message
54
+ def format_constraint_error(error)
55
+ format_constraint_message(error.message)
56
+ end
57
+
58
+ private
59
+
60
+ # Format a constraint violation message
61
+ def format_constraint_message(message)
62
+ # Parse predicate and args from messages like:
63
+ # "max_size?(50, \"value\") failed" or "gt?(0, 0) failed"
64
+ if (match = message.match(/(\w+\?)\(([^)]*)\)/))
65
+ predicate = match[1]
66
+ args_str = match[2]
67
+ args = parse_args(args_str)
68
+
69
+ lookup_message(predicate, *args) || fallback_message(predicate, args)
70
+ else
71
+ message
72
+ end
73
+ end
74
+
75
+ # Look up a message from dry-schema's message templates
76
+ def lookup_message(predicate, *args)
77
+ # Convert predicate to symbol without ?
78
+ key = predicate.to_s.chomp("?").to_sym
79
+
80
+ # Try to get message from dry-schema
81
+ begin
82
+ result = MESSAGES.call(key, path: [:base], tokens: build_tokens(key, args))
83
+ result&.text
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ end
88
+
89
+ # Build tokens hash for message interpolation
90
+ def build_tokens(key, args)
91
+ case key
92
+ when :max_size, :min_size, :size
93
+ {num: args[0]}
94
+ when :gt, :gteq, :lt, :lteq
95
+ {num: args[0]}
96
+ when :included_in, :excluded_from
97
+ {list: args[0]}
98
+ when :format
99
+ {format: args[0]}
100
+ else
101
+ {num: args[0]}
102
+ end
103
+ end
104
+
105
+ # Fallback messages when dry-schema lookup fails
106
+ def fallback_message(predicate, args)
107
+ case predicate
108
+ when "max_size?"
109
+ "size cannot be greater than #{args[0]}"
110
+ when "min_size?"
111
+ "size cannot be less than #{args[0]}"
112
+ when "gt?"
113
+ "must be greater than #{args[0]}"
114
+ when "gteq?"
115
+ "must be greater than or equal to #{args[0]}"
116
+ when "lt?"
117
+ "must be less than #{args[0]}"
118
+ when "lteq?"
119
+ "must be less than or equal to #{args[0]}"
120
+ when "included_in?"
121
+ "must be one of: #{args[0]}"
122
+ when "format?"
123
+ "is in invalid format"
124
+ when "filled?"
125
+ "must be filled"
126
+ else
127
+ "is invalid"
128
+ end
129
+ end
130
+
131
+ # Parse args from constraint error message
132
+ def parse_args(args_str)
133
+ # Simple parsing - split by comma and clean up
134
+ args_str.split(",").map do |arg|
135
+ arg = arg.strip
136
+ if arg.start_with?('"') && arg.end_with?('"')
137
+ arg[1..-2] # Remove quotes
138
+ elsif arg.match?(/^-?\d+$/)
139
+ arg.to_i
140
+ elsif arg.match?(/^-?\d+\.\d+$/)
141
+ arg.to_f
142
+ else
143
+ arg
144
+ end
145
+ end
146
+ end
147
+
148
+ # Extract type name from error message
149
+ def extract_type_name(message)
150
+ case message
151
+ when /Integer/ then "integer"
152
+ when /Float/ then "number"
153
+ when /String/ then "string"
154
+ when /Bool|TrueClass|FalseClass/ then "boolean"
155
+ when /Date/ then "date"
156
+ when /Array/ then "array"
157
+ when /Hash/ then "object"
158
+ else "value"
159
+ end
160
+ end
161
+
162
+ # Format a value for display in error messages
163
+ def format_value(value)
164
+ return "nil" if value.nil?
165
+
166
+ str = value.to_s
167
+ (str.length > 50) ? "#{str[0, 50]}..." : str
168
+ end
169
+ end
170
+ end
171
+ end
@@ -87,4 +87,28 @@ module ZeroRuby
87
87
  "ooo"
88
88
  end
89
89
  end
90
+
91
+ # Raised for database transaction errors.
92
+ # Triggers top-level PushFailed with reason: "database"
93
+ # Wraps unexpected errors during transaction execution (matching TS behavior).
94
+ class DatabaseTransactionError < Error
95
+ end
96
+
97
+ # Raised when push data is malformed or missing required fields.
98
+ # Triggers top-level PushFailed with reason: "parse"
99
+ class ParseError < Error
100
+ end
101
+
102
+ # Raised for unexpected internal server errors.
103
+ # Wrapped as app error per Zero protocol (no "internal" error type at mutation level)
104
+ class InternalError < Error
105
+ end
106
+
107
+ # Raised when a mutation doesn't call the transact block.
108
+ # All mutations MUST call transact.call { ... } to wrap their database operations.
109
+ class TransactNotCalledError < Error
110
+ def initialize
111
+ super("Mutation must call transact block")
112
+ end
113
+ end
90
114
  end