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.
- checksums.yaml +4 -4
- data/README.md +141 -90
- data/lib/zero_ruby/configuration.rb +0 -5
- data/lib/zero_ruby/error_formatter.rb +171 -0
- data/lib/zero_ruby/errors.rb +24 -0
- 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 +206 -80
- data/lib/zero_ruby/push_processor.rb +134 -37
- data/lib/zero_ruby/schema.rb +64 -16
- 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 +48 -22
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d78f7995f5a44093ed0202e53dd345587f73a2d2a675e31c158fe01ed392e6e
|
|
4
|
+
data.tar.gz: 9fbbb4cdce370fca8c035e0fad0cd0dbbeaa9ace5cf92061cdab6274223987ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 &
|
|
10
|
-
- **Type generation** - Generates
|
|
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.
|
|
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
|
-
|
|
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
|
|
66
|
-
argument :post_input, Types::PostInput
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 =
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
validates: {
|
|
206
|
-
numericality: { greater_than: 0, less_than: 150 }
|
|
207
|
-
}
|
|
196
|
+
## Validation with Constraints
|
|
208
197
|
|
|
209
|
-
|
|
210
|
-
validates: {
|
|
211
|
-
inclusion: { in: %w[draft published archived] }
|
|
212
|
-
}
|
|
198
|
+
Use dry-types constraints for validation:
|
|
213
199
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
+
## Type coercion
|
|
242
|
+
|
|
243
|
+
Types automatically coerce compatible values:
|
|
229
244
|
|
|
230
245
|
| Type | Accepts | Rejects |
|
|
231
246
|
|------|---------|---------|
|
|
232
|
-
| `String` |
|
|
233
|
-
| `Integer` | `42`, `"42"`, `3.7` → `3` | `"abc"`, `""
|
|
234
|
-
| `Float` | `3.14`, `"3.14"`, `42` → `42.0` | `"abc"`, `""
|
|
235
|
-
| `Boolean` | `true`, `false`, `"true"`, `"false"
|
|
236
|
-
| `ID` | `"abc"
|
|
237
|
-
| `
|
|
238
|
-
| `
|
|
239
|
-
|
|
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
|
-
-
|
|
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
|
data/lib/zero_ruby/errors.rb
CHANGED
|
@@ -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
|