zero_ruby 0.1.0.alpha1
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 +7 -0
- data/CHANGELOG.md +0 -0
- data/LICENSE.txt +21 -0
- data/README.md +245 -0
- data/lib/zero_ruby/argument.rb +75 -0
- data/lib/zero_ruby/configuration.rb +57 -0
- data/lib/zero_ruby/errors.rb +90 -0
- data/lib/zero_ruby/input_object.rb +121 -0
- data/lib/zero_ruby/lmid_store.rb +43 -0
- data/lib/zero_ruby/lmid_stores/active_record_store.rb +63 -0
- data/lib/zero_ruby/mutation.rb +141 -0
- data/lib/zero_ruby/push_processor.rb +126 -0
- data/lib/zero_ruby/schema.rb +124 -0
- data/lib/zero_ruby/type_names.rb +24 -0
- data/lib/zero_ruby/types/base_type.rb +54 -0
- data/lib/zero_ruby/types/big_int.rb +32 -0
- data/lib/zero_ruby/types/boolean.rb +30 -0
- data/lib/zero_ruby/types/float.rb +31 -0
- data/lib/zero_ruby/types/id.rb +33 -0
- data/lib/zero_ruby/types/integer.rb +31 -0
- data/lib/zero_ruby/types/iso8601_date.rb +43 -0
- data/lib/zero_ruby/types/iso8601_date_time.rb +43 -0
- data/lib/zero_ruby/types/string.rb +20 -0
- data/lib/zero_ruby/typescript_generator.rb +192 -0
- data/lib/zero_ruby/validator.rb +69 -0
- data/lib/zero_ruby/validators/allow_blank_validator.rb +31 -0
- data/lib/zero_ruby/validators/allow_null_validator.rb +26 -0
- data/lib/zero_ruby/validators/exclusion_validator.rb +29 -0
- data/lib/zero_ruby/validators/format_validator.rb +35 -0
- data/lib/zero_ruby/validators/inclusion_validator.rb +30 -0
- data/lib/zero_ruby/validators/length_validator.rb +42 -0
- data/lib/zero_ruby/validators/numericality_validator.rb +63 -0
- data/lib/zero_ruby/version.rb +5 -0
- data/lib/zero_ruby/zero_client.rb +25 -0
- data/lib/zero_ruby.rb +87 -0
- metadata +145 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ba93bb14b442ea97c4ac597b0c97e7b9e0ec00a334e5f2fd6bb3ee7639d47302
|
|
4
|
+
data.tar.gz: 741f8f22deb20a49cf7ffd75a03657faf28d561ad32a03debb74f4f5b4e41de8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bab7af54647503b8322eef911336dd9abfe952eaa574baa7f61da57e72e234754a03287354f24af6022b15731097c43a51b5ba67ecb441d0dcc148de834b7b2d
|
|
7
|
+
data.tar.gz: 9a4015f062da3df3cdfbc32fc00f747046dee0a30d03228995b59049b9ce2a44c0df10836db173f906ceb8aebd76d935a116576d74848187c88470b7aeb3c4bd
|
data/CHANGELOG.md
ADDED
|
File without changes
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alex Serban
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# zero_ruby
|
|
2
|
+
|
|
3
|
+
A Ruby gem for handling [Zero](https://zero.rocicorp.dev/) mutations with type safety, validation, and full protocol support.
|
|
4
|
+
|
|
5
|
+
0.1.0.alpha1
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
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.
|
|
12
|
+
- **LMID tracking** - Duplicate and out-of-order mutation detection using Zero's `zero_0.clients` table
|
|
13
|
+
- **Push protocol** - Version validation, transaction wrapping, retry logic
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'zero_ruby'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
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
|
+
```
|
|
58
|
+
|
|
59
|
+
### 3. Define mutations
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# app/zero/mutations/post_update.rb
|
|
63
|
+
module Mutations
|
|
64
|
+
class PostUpdate < ApplicationMutation
|
|
65
|
+
argument :id, ID, required: true
|
|
66
|
+
argument :post_input, Types::PostInput, required: true
|
|
67
|
+
|
|
68
|
+
def execute(id:, post_input:)
|
|
69
|
+
post = current_user.posts.find(id)
|
|
70
|
+
post.update!(**post_input)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 4. Register mutations in schema
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# app/zero/app_schema.rb
|
|
80
|
+
# The mutation names should match the names used in your Zero client:
|
|
81
|
+
# mutators.posts.update({ id: "...", post_input: { title: "..." } })
|
|
82
|
+
# -> maps to "posts.update"
|
|
83
|
+
class ZeroSchema < ZeroRuby::Schema
|
|
84
|
+
mutation "posts.update", handler: Mutations::PostUpdate
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 5. Add controller
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# app/controllers/zero_controller.rb
|
|
92
|
+
class ZeroController < ApplicationController
|
|
93
|
+
# Skip CSRF for API endpoint
|
|
94
|
+
# skip_before_action :verify_authenticity_token
|
|
95
|
+
|
|
96
|
+
def push
|
|
97
|
+
if request.get?
|
|
98
|
+
# GET requests return TypeScript type definitions
|
|
99
|
+
render plain: ZeroSchema.to_typescript, content_type: "text/plain; charset=utf-8"
|
|
100
|
+
else
|
|
101
|
+
# POST requests process mutations
|
|
102
|
+
body = JSON.parse(request.body.read)
|
|
103
|
+
|
|
104
|
+
# Build context hash with whatever your mutations need.
|
|
105
|
+
# Access in mutations via ctx[:current_user]
|
|
106
|
+
context = {
|
|
107
|
+
current_user: current_user,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
result = ZeroSchema.execute(body, context: context)
|
|
111
|
+
render json: result
|
|
112
|
+
end
|
|
113
|
+
rescue JSON::ParserError => e
|
|
114
|
+
render json: {
|
|
115
|
+
error: {
|
|
116
|
+
kind: "PushFailed",
|
|
117
|
+
reason: "Parse",
|
|
118
|
+
message: "Invalid JSON: #{e.message}"
|
|
119
|
+
}
|
|
120
|
+
}, status: :bad_request
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 6. Add route
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# config/routes.rb
|
|
129
|
+
match '/zero/push', to: 'zero#push', via: [:get, :post]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
Create an initializer to customize settings (all options have sensible defaults):
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# config/initializers/zero_ruby.rb
|
|
138
|
+
ZeroRuby.configure do |config|
|
|
139
|
+
# Storage backend (:active_record is the only built-in option)
|
|
140
|
+
config.lmid_store = :active_record
|
|
141
|
+
|
|
142
|
+
# Retry attempts for transient errors
|
|
143
|
+
config.max_retry_attempts = 3
|
|
144
|
+
|
|
145
|
+
# Push protocol version (reject requests with different version)
|
|
146
|
+
config.supported_push_version = 1
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## TypeScript type generation
|
|
151
|
+
|
|
152
|
+
ZeroRuby generates TypeScript type definitions from your Ruby mutations. GET requests to `/zero/push` return the types.
|
|
153
|
+
|
|
154
|
+
### Setup
|
|
155
|
+
|
|
156
|
+
- Set `ZERO_TYPES_URL` env var to your host `http://example.com/zero/push`
|
|
157
|
+
- `npm install ts-to-zod --save-dev`
|
|
158
|
+
- Add the following script to generate types and zod schemas
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"scripts": {
|
|
163
|
+
"zero:types": "mkdir -p lib/zero/__generated__ && curl -s $ZERO_TYPES_URL/zero/push > lib/zero/__generated__/zero-types.ts && npx ts-to-zod lib/zero/__generated__/zero-types.ts lib/zero/__generated__/zero-schemas.ts"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Using with Zero Mutators
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { defineMutator, defineMutators } from '@rocicorp/zero'
|
|
172
|
+
import {
|
|
173
|
+
postsCreateArgsSchema,
|
|
174
|
+
postsUpdateArgsSchema,
|
|
175
|
+
} from './zero/__generated__/zero-schemas'
|
|
176
|
+
|
|
177
|
+
export const mutators = defineMutators({
|
|
178
|
+
posts: {
|
|
179
|
+
update: defineMutator(postsUpdateArgsSchema, async ({ tx, args }) => {
|
|
180
|
+
await tx.mutate.posts.update({
|
|
181
|
+
id: args.id,
|
|
182
|
+
...(args.postInput.title !== undefined && { title: args.postInput.title }),
|
|
183
|
+
updatedAt: Date.now(),
|
|
184
|
+
})
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
export type Mutators = typeof mutators
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Validation
|
|
193
|
+
|
|
194
|
+
Built-in validators:
|
|
195
|
+
|
|
196
|
+
```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
|
+
}
|
|
203
|
+
|
|
204
|
+
argument :age, Integer, required: true,
|
|
205
|
+
validates: {
|
|
206
|
+
numericality: { greater_than: 0, less_than: 150 }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
argument :status, String, required: true,
|
|
210
|
+
validates: {
|
|
211
|
+
inclusion: { in: %w[draft published archived] }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
argument :username, String, required: true,
|
|
215
|
+
validates: {
|
|
216
|
+
exclusion: { in: %w[admin root system], message: "is reserved" }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
argument :email, String, required: true,
|
|
220
|
+
validates: {
|
|
221
|
+
allow_null: false,
|
|
222
|
+
allow_blank: false
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Type coercion & checking
|
|
227
|
+
|
|
228
|
+
Types automatically coerce compatible values and raise `CoercionError` for invalid input:
|
|
229
|
+
|
|
230
|
+
| Type | Accepts | Rejects |
|
|
231
|
+
|------|---------|---------|
|
|
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 |
|
|
240
|
+
|
|
241
|
+
## References
|
|
242
|
+
|
|
243
|
+
- [Zero Documentation](https://zero.rocicorp.dev/docs/mutators)
|
|
244
|
+
- [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)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRuby
|
|
4
|
+
# Represents a declared argument for a mutation.
|
|
5
|
+
# Holds type, required status, and validation configuration.
|
|
6
|
+
class Argument
|
|
7
|
+
# Sentinel value to distinguish "no default provided" from "default is nil"
|
|
8
|
+
NOT_PROVIDED = Object.new.freeze
|
|
9
|
+
|
|
10
|
+
# Maps Ruby built-in classes to ZeroRuby types.
|
|
11
|
+
# This allows using String, Integer, Float directly in argument declarations.
|
|
12
|
+
RUBY_TYPE_MAP = {
|
|
13
|
+
::String => -> { ZeroRuby::Types::String },
|
|
14
|
+
::Integer => -> { ZeroRuby::Types::Integer },
|
|
15
|
+
::Float => -> { ZeroRuby::Types::Float }
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :name, :type, :required, :validators, :default, :description
|
|
19
|
+
|
|
20
|
+
def initialize(name:, type:, required: true, validates: nil, default: NOT_PROVIDED, description: nil, **options)
|
|
21
|
+
@name = name.to_sym
|
|
22
|
+
@type = resolve_type(type)
|
|
23
|
+
@required = required
|
|
24
|
+
@validators = validates || {}
|
|
25
|
+
@has_default = default != NOT_PROVIDED
|
|
26
|
+
@default = @has_default ? default : nil
|
|
27
|
+
@description = description
|
|
28
|
+
@options = options
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def required?
|
|
32
|
+
@required
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def optional?
|
|
36
|
+
!@required
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def has_default?
|
|
40
|
+
@has_default
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Coerce and validate a raw input value
|
|
44
|
+
# @param raw_value [Object] The raw input value
|
|
45
|
+
# @param ctx [Hash] The context hash
|
|
46
|
+
# @return [Object] The coerced value
|
|
47
|
+
def coerce(raw_value, ctx = nil)
|
|
48
|
+
value = (raw_value.nil? && has_default?) ? @default : raw_value
|
|
49
|
+
|
|
50
|
+
# Handle InputObject types (they use .coerce instead of .coerce_input)
|
|
51
|
+
if input_object_type?
|
|
52
|
+
@type.coerce(value, ctx)
|
|
53
|
+
else
|
|
54
|
+
@type.coerce_input(value, ctx)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Resolve a type reference to a ZeroRuby type.
|
|
61
|
+
# Handles Ruby built-in classes (String, Integer, Float) by mapping them
|
|
62
|
+
# to the corresponding ZeroRuby::Types class.
|
|
63
|
+
def resolve_type(type)
|
|
64
|
+
if RUBY_TYPE_MAP.key?(type)
|
|
65
|
+
RUBY_TYPE_MAP[type].call
|
|
66
|
+
else
|
|
67
|
+
type
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def input_object_type?
|
|
72
|
+
defined?(ZeroRuby::InputObject) && @type.is_a?(Class) && @type < ZeroRuby::InputObject
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRuby
|
|
4
|
+
# Configuration class for ZeroRuby.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# ZeroRuby.configure do |config|
|
|
8
|
+
# config.lmid_store = :active_record
|
|
9
|
+
# config.max_retry_attempts = 3
|
|
10
|
+
# end
|
|
11
|
+
class Configuration
|
|
12
|
+
# LMID (Last Mutation ID) tracking settings
|
|
13
|
+
# The LMID store backend: :active_record or a custom LmidStore instance
|
|
14
|
+
attr_accessor :lmid_store
|
|
15
|
+
|
|
16
|
+
# Maximum retry attempts for ApplicationError during mutation processing
|
|
17
|
+
attr_accessor :max_retry_attempts
|
|
18
|
+
|
|
19
|
+
# The push version supported by this configuration
|
|
20
|
+
attr_accessor :supported_push_version
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@lmid_store = :active_record
|
|
24
|
+
@max_retry_attempts = 3
|
|
25
|
+
@supported_push_version = 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get the configured LMID store instance
|
|
29
|
+
# @return [LmidStore] The LMID store instance
|
|
30
|
+
def lmid_store_instance
|
|
31
|
+
case @lmid_store
|
|
32
|
+
when :active_record
|
|
33
|
+
LmidStores::ActiveRecordStore.new
|
|
34
|
+
when LmidStore
|
|
35
|
+
@lmid_store
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Unknown LMID store: #{@lmid_store.inspect}. Use :active_record or pass a custom LmidStore instance."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
attr_writer :configuration
|
|
44
|
+
|
|
45
|
+
def configuration
|
|
46
|
+
@configuration ||= Configuration.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def configure
|
|
50
|
+
yield(configuration)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset_configuration!
|
|
54
|
+
@configuration = Configuration.new
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRuby
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :details
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, details: nil)
|
|
8
|
+
@details = details
|
|
9
|
+
super(message)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns the Zero protocol error type string
|
|
13
|
+
def error_type
|
|
14
|
+
"app"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ValidationError < Error
|
|
19
|
+
attr_reader :errors
|
|
20
|
+
|
|
21
|
+
def initialize(errors)
|
|
22
|
+
@errors = Array(errors)
|
|
23
|
+
super(@errors.join(", "))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Raised when a value cannot be coerced to the expected type
|
|
28
|
+
class CoercionError < Error
|
|
29
|
+
attr_reader :value, :expected_type
|
|
30
|
+
|
|
31
|
+
def initialize(message, value: nil, expected_type: nil)
|
|
32
|
+
@value = value
|
|
33
|
+
@expected_type = expected_type
|
|
34
|
+
super(message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Raised when a mutation is not found in the schema
|
|
39
|
+
class MutationNotFoundError < Error
|
|
40
|
+
attr_reader :mutation_name
|
|
41
|
+
|
|
42
|
+
def initialize(mutation_name)
|
|
43
|
+
@mutation_name = mutation_name
|
|
44
|
+
super("Unknown mutation: #{mutation_name}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raised when pushVersion is not supported
|
|
49
|
+
class UnsupportedPushVersionError < Error
|
|
50
|
+
attr_reader :received_version, :supported_version
|
|
51
|
+
|
|
52
|
+
def initialize(received_version, supported_version: 1)
|
|
53
|
+
@received_version = received_version
|
|
54
|
+
@supported_version = supported_version
|
|
55
|
+
super("Unsupported push version: #{received_version}. Expected: #{supported_version}")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Raised when a mutation has already been processed (duplicate)
|
|
60
|
+
class MutationAlreadyProcessedError < Error
|
|
61
|
+
attr_reader :client_id, :received_id, :last_mutation_id
|
|
62
|
+
|
|
63
|
+
def initialize(client_id:, received_id:, last_mutation_id:)
|
|
64
|
+
@client_id = client_id
|
|
65
|
+
@received_id = received_id
|
|
66
|
+
@last_mutation_id = last_mutation_id
|
|
67
|
+
super("Mutation #{received_id} already processed for client #{client_id}. Last mutation ID: #{last_mutation_id}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def error_type
|
|
71
|
+
"alreadyProcessed"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Raised when mutations arrive out of order
|
|
76
|
+
class OutOfOrderMutationError < Error
|
|
77
|
+
attr_reader :client_id, :received_id, :expected_id
|
|
78
|
+
|
|
79
|
+
def initialize(client_id:, received_id:, expected_id:)
|
|
80
|
+
@client_id = client_id
|
|
81
|
+
@received_id = received_id
|
|
82
|
+
@expected_id = expected_id
|
|
83
|
+
super("Client #{client_id} sent mutation ID #{received_id} but expected #{expected_id}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def error_type
|
|
87
|
+
"ooo"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "argument"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "validator"
|
|
6
|
+
|
|
7
|
+
module ZeroRuby
|
|
8
|
+
# Base class for input objects (nested argument types).
|
|
9
|
+
# Similar to GraphQL-Ruby's InputObject pattern.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class Types::PostInput < ZeroRuby::InputObject
|
|
13
|
+
# argument :id, ID, required: true
|
|
14
|
+
# argument :title, String, required: true
|
|
15
|
+
# argument :body, String, required: false
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# class PostCreate < ZeroRuby::Mutation
|
|
19
|
+
# argument :post_input, Types::PostInput, required: true
|
|
20
|
+
# argument :notify, Boolean, required: false
|
|
21
|
+
#
|
|
22
|
+
# def execute(post_input:, notify: nil)
|
|
23
|
+
# Post.create!(**post_input)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
class InputObject
|
|
27
|
+
include TypeNames
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Declare an argument for this input object
|
|
31
|
+
def argument(name, type, required: true, validates: nil, default: Argument::NOT_PROVIDED, description: nil, **options)
|
|
32
|
+
arguments[name.to_sym] = Argument.new(
|
|
33
|
+
name: name,
|
|
34
|
+
type: type,
|
|
35
|
+
required: required,
|
|
36
|
+
validates: validates,
|
|
37
|
+
default: default,
|
|
38
|
+
description: description,
|
|
39
|
+
**options
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get all declared arguments (including inherited)
|
|
44
|
+
def arguments
|
|
45
|
+
@arguments ||= if superclass.respond_to?(:arguments)
|
|
46
|
+
superclass.arguments.dup
|
|
47
|
+
else
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Coerce and validate raw input
|
|
53
|
+
# @param raw_args [Hash] Raw input
|
|
54
|
+
# @param ctx [Hash] The context hash
|
|
55
|
+
# @return [Hash] Validated and coerced hash (only includes keys present in input or with defaults)
|
|
56
|
+
# @raise [ZeroRuby::ValidationError] If validation fails
|
|
57
|
+
def coerce(value, ctx)
|
|
58
|
+
return nil if value.nil?
|
|
59
|
+
return nil unless value.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
validated = {}
|
|
62
|
+
errors = []
|
|
63
|
+
|
|
64
|
+
arguments.each do |name, arg|
|
|
65
|
+
key_present = value.key?(name) || value.key?(name.to_s)
|
|
66
|
+
val = if key_present
|
|
67
|
+
value[name].nil? ? value[name.to_s] : value[name]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check required
|
|
71
|
+
if arg.required? && !key_present && !arg.has_default?
|
|
72
|
+
errors << "#{name} is required"
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Apply default if key not present
|
|
77
|
+
if !key_present && arg.has_default?
|
|
78
|
+
validated[name] = arg.default
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Skip if key not present (don't add to validated hash)
|
|
83
|
+
next unless key_present
|
|
84
|
+
|
|
85
|
+
# Handle nil values - include in hash but skip coercion
|
|
86
|
+
if val.nil?
|
|
87
|
+
validated[name] = nil
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Type coercion (handles nested InputObjects)
|
|
92
|
+
begin
|
|
93
|
+
coerced = arg.coerce(val, ctx)
|
|
94
|
+
rescue CoercionError => e
|
|
95
|
+
errors << "#{name}: #{e.message}"
|
|
96
|
+
next
|
|
97
|
+
rescue ValidationError => e
|
|
98
|
+
# Nested InputObject validation errors - prefix with field name
|
|
99
|
+
e.errors.each do |err|
|
|
100
|
+
errors << "#{name}.#{err}"
|
|
101
|
+
end
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Run validators
|
|
106
|
+
if arg.validators.any?
|
|
107
|
+
validation_errors = Validator.validate!(arg.validators, nil, ctx, coerced)
|
|
108
|
+
validation_errors.each do |err|
|
|
109
|
+
errors << "#{name} #{err}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
validated[name] = coerced
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
raise ValidationError.new(errors) if errors.any?
|
|
117
|
+
validated
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRuby
|
|
4
|
+
# Abstract base class for LMID (Last Mutation ID) storage backends.
|
|
5
|
+
# Implementations must provide thread-safe access to client mutation IDs.
|
|
6
|
+
#
|
|
7
|
+
# This follows the same atomic increment pattern as Zero's TypeScript implementation.
|
|
8
|
+
# @see https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/zql-database.ts
|
|
9
|
+
#
|
|
10
|
+
# @example Custom store implementation
|
|
11
|
+
# class RedisLmidStore < ZeroRuby::LmidStore
|
|
12
|
+
# def fetch_and_increment(client_group_id, client_id)
|
|
13
|
+
# # Atomically increment and return the new last mutation ID
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# def transaction
|
|
17
|
+
# # Redis MULTI/EXEC
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
class LmidStore
|
|
21
|
+
# Atomically increment and return the last mutation ID for a client.
|
|
22
|
+
# Creates the record with ID 1 if it doesn't exist.
|
|
23
|
+
#
|
|
24
|
+
# This must be atomic to prevent race conditions - the increment and
|
|
25
|
+
# return should happen in a single operation.
|
|
26
|
+
#
|
|
27
|
+
# @param client_group_id [String] The client group ID
|
|
28
|
+
# @param client_id [String] The client ID
|
|
29
|
+
# @return [Integer] The new last mutation ID (post-increment)
|
|
30
|
+
def fetch_and_increment(client_group_id, client_id)
|
|
31
|
+
raise NotImplementedError, "#{self.class}#fetch_and_increment must be implemented"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Execute a block within a transaction.
|
|
35
|
+
# The transaction should support rollback on error.
|
|
36
|
+
#
|
|
37
|
+
# @yield The block to execute within the transaction
|
|
38
|
+
# @return The result of the block
|
|
39
|
+
def transaction(&block)
|
|
40
|
+
raise NotImplementedError, "#{self.class}#transaction must be implemented"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|