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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +0 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +245 -0
  5. data/lib/zero_ruby/argument.rb +75 -0
  6. data/lib/zero_ruby/configuration.rb +57 -0
  7. data/lib/zero_ruby/errors.rb +90 -0
  8. data/lib/zero_ruby/input_object.rb +121 -0
  9. data/lib/zero_ruby/lmid_store.rb +43 -0
  10. data/lib/zero_ruby/lmid_stores/active_record_store.rb +63 -0
  11. data/lib/zero_ruby/mutation.rb +141 -0
  12. data/lib/zero_ruby/push_processor.rb +126 -0
  13. data/lib/zero_ruby/schema.rb +124 -0
  14. data/lib/zero_ruby/type_names.rb +24 -0
  15. data/lib/zero_ruby/types/base_type.rb +54 -0
  16. data/lib/zero_ruby/types/big_int.rb +32 -0
  17. data/lib/zero_ruby/types/boolean.rb +30 -0
  18. data/lib/zero_ruby/types/float.rb +31 -0
  19. data/lib/zero_ruby/types/id.rb +33 -0
  20. data/lib/zero_ruby/types/integer.rb +31 -0
  21. data/lib/zero_ruby/types/iso8601_date.rb +43 -0
  22. data/lib/zero_ruby/types/iso8601_date_time.rb +43 -0
  23. data/lib/zero_ruby/types/string.rb +20 -0
  24. data/lib/zero_ruby/typescript_generator.rb +192 -0
  25. data/lib/zero_ruby/validator.rb +69 -0
  26. data/lib/zero_ruby/validators/allow_blank_validator.rb +31 -0
  27. data/lib/zero_ruby/validators/allow_null_validator.rb +26 -0
  28. data/lib/zero_ruby/validators/exclusion_validator.rb +29 -0
  29. data/lib/zero_ruby/validators/format_validator.rb +35 -0
  30. data/lib/zero_ruby/validators/inclusion_validator.rb +30 -0
  31. data/lib/zero_ruby/validators/length_validator.rb +42 -0
  32. data/lib/zero_ruby/validators/numericality_validator.rb +63 -0
  33. data/lib/zero_ruby/version.rb +5 -0
  34. data/lib/zero_ruby/zero_client.rb +25 -0
  35. data/lib/zero_ruby.rb +87 -0
  36. 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