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
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "base_type"
5
+
6
+ module ZeroRuby
7
+ module Types
8
+ # ISO8601Date type for date values.
9
+ # Accepts ISO8601 formatted date strings, Date, Time, and DateTime objects.
10
+ # Coerces to Date.
11
+ class ISO8601Date < BaseType
12
+ class << self
13
+ def name
14
+ "ISO8601Date"
15
+ end
16
+
17
+ def coerce_input(value, _ctx = nil)
18
+ return nil if value.nil?
19
+
20
+ case value
21
+ when ::Date
22
+ value
23
+ when ::Time, ::DateTime
24
+ value.to_date
25
+ when ::String
26
+ coercion_error!(value, "empty string is not a valid #{name}") if value.empty?
27
+ parse_iso8601_date(value)
28
+ else
29
+ coercion_error!(value, "#{format_value_for_error(value)} (#{value.class}) cannot be coerced to #{name}")
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def parse_iso8601_date(value)
36
+ Date.iso8601(value)
37
+ rescue ArgumentError
38
+ coercion_error!(value, "#{format_value_for_error(value)} is not a valid ISO8601 date")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "base_type"
5
+
6
+ module ZeroRuby
7
+ module Types
8
+ # ISO8601DateTime type for datetime values.
9
+ # Accepts ISO8601 formatted strings, Time, and DateTime objects.
10
+ # Coerces to Time.
11
+ class ISO8601DateTime < BaseType
12
+ class << self
13
+ def name
14
+ "ISO8601DateTime"
15
+ end
16
+
17
+ def coerce_input(value, _ctx = nil)
18
+ return nil if value.nil?
19
+
20
+ case value
21
+ when ::Time
22
+ value
23
+ when ::DateTime
24
+ value.to_time
25
+ when ::String
26
+ coercion_error!(value, "empty string is not a valid #{name}") if value.empty?
27
+ parse_iso8601(value)
28
+ else
29
+ coercion_error!(value, "#{format_value_for_error(value)} (#{value.class}) cannot be coerced to #{name}")
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def parse_iso8601(value)
36
+ Time.iso8601(value)
37
+ rescue ArgumentError
38
+ coercion_error!(value, "#{format_value_for_error(value)} is not a valid ISO8601 datetime")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_type"
4
+
5
+ module ZeroRuby
6
+ module Types
7
+ class String < BaseType
8
+ class << self
9
+ def name
10
+ "String"
11
+ end
12
+
13
+ def coerce_input(value, _ctx = nil)
14
+ return nil if value.nil?
15
+ value.to_s
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRuby
4
+ # Generates TypeScript type definitions from registered mutations.
5
+ # Similar to graphql-codegen, this introspects mutation argument definitions
6
+ # and generates corresponding TypeScript interfaces.
7
+ #
8
+ # @example
9
+ # TypeScriptGenerator.new(ZeroSchema).generate
10
+ # # => "// Auto-generated by zero-ruby - do not edit\n\nexport interface ..."
11
+ class TypeScriptGenerator
12
+ TYPE_MAP = {
13
+ "ZeroRuby::Types::String" => "string",
14
+ "ZeroRuby::Types::Integer" => "number",
15
+ "ZeroRuby::Types::Float" => "number",
16
+ "ZeroRuby::Types::Boolean" => "boolean",
17
+ "ZeroRuby::Types::ID" => "string",
18
+ "ZeroRuby::Types::BigInt" => "number",
19
+ "ZeroRuby::Types::ISO8601Date" => "string",
20
+ "ZeroRuby::Types::ISO8601DateTime" => "string"
21
+ }.freeze
22
+
23
+ def initialize(schema)
24
+ @schema = schema
25
+ @input_objects = {}
26
+ end
27
+
28
+ # Generate TypeScript definitions
29
+ # @return [String] Complete TypeScript type definitions
30
+ def generate
31
+ collect_input_objects
32
+
33
+ parts = [
34
+ generate_header,
35
+ generate_scalars,
36
+ generate_input_objects,
37
+ generate_mutation_args,
38
+ generate_mutation_map
39
+ ]
40
+
41
+ parts.compact.join("\n")
42
+ end
43
+
44
+ private
45
+
46
+ def generate_header
47
+ <<~TS
48
+ // Auto-generated by zero-ruby - do not edit
49
+ // Generated at: #{Time.now.utc.iso8601}
50
+ TS
51
+ end
52
+
53
+ def generate_scalars
54
+ <<~TS
55
+
56
+ /** Scalar type mappings */
57
+ export type Scalars = {
58
+ String: string;
59
+ Integer: number;
60
+ Float: number;
61
+ Boolean: boolean;
62
+ };
63
+ TS
64
+ end
65
+
66
+ def collect_input_objects
67
+ @schema.mutations.each do |_name, handler_class|
68
+ collect_input_objects_from_arguments(handler_class.arguments)
69
+ end
70
+ end
71
+
72
+ def collect_input_objects_from_arguments(arguments)
73
+ arguments.each do |_name, arg|
74
+ if input_object_type?(arg.type)
75
+ type_name = extract_type_name(arg.type)
76
+ unless @input_objects.key?(type_name)
77
+ @input_objects[type_name] = arg.type
78
+ # Recursively collect nested InputObjects
79
+ collect_input_objects_from_arguments(arg.type.arguments)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def generate_input_objects
86
+ return nil if @input_objects.empty?
87
+
88
+ interfaces = @input_objects.map do |name, klass|
89
+ generate_interface(name, klass.arguments)
90
+ end
91
+
92
+ "\n" + interfaces.join("\n\n")
93
+ end
94
+
95
+ def generate_mutation_args
96
+ return nil if @schema.mutations.empty?
97
+
98
+ interfaces = @schema.mutations.map do |name, handler_class|
99
+ interface_name = to_interface_name(name)
100
+ generate_interface(interface_name, handler_class.arguments)
101
+ end
102
+
103
+ "\n" + interfaces.join("\n\n")
104
+ end
105
+
106
+ def generate_interface(name, arguments)
107
+ if arguments.empty?
108
+ return <<~TS.strip
109
+ /** #{name} */
110
+ export interface #{name} {}
111
+ TS
112
+ end
113
+
114
+ fields = arguments.map do |arg_name, arg|
115
+ optional = arg.optional? ? "?" : ""
116
+ ts_type = resolve_type(arg.type)
117
+ description = arg.description ? " /** #{arg.description} */\n" : ""
118
+ "#{description} #{to_camel_case(arg_name)}#{optional}: #{ts_type};"
119
+ end
120
+
121
+ <<~TS.strip
122
+ /** #{name} */
123
+ export interface #{name} {
124
+ #{fields.join("\n")}
125
+ }
126
+ TS
127
+ end
128
+
129
+ def generate_mutation_map
130
+ return nil if @schema.mutations.empty?
131
+
132
+ entries = @schema.mutations.map do |name, _handler_class|
133
+ interface_name = to_interface_name(name)
134
+ " \"#{name}\": #{interface_name};"
135
+ end
136
+
137
+ <<~TS
138
+
139
+ /** All mutations mapped by name */
140
+ export interface MutationArgs {
141
+ #{entries.join("\n")}
142
+ }
143
+
144
+ export type MutationName = keyof MutationArgs;
145
+ export type ArgsFor<T extends MutationName> = MutationArgs[T];
146
+ TS
147
+ end
148
+
149
+ def resolve_type(type)
150
+ type_key = type.to_s
151
+
152
+ if TYPE_MAP.key?(type_key)
153
+ TYPE_MAP[type_key]
154
+ elsif input_object_type?(type)
155
+ extract_type_name(type)
156
+ else
157
+ # Unknown type, default to unknown
158
+ "unknown"
159
+ end
160
+ end
161
+
162
+ def input_object_type?(type)
163
+ defined?(ZeroRuby::InputObject) && type.is_a?(Class) && type < ZeroRuby::InputObject
164
+ end
165
+
166
+ def extract_type_name(type)
167
+ # Get just the class name without module prefixes
168
+ type.name.split("::").last
169
+ end
170
+
171
+ # Convert mutation name to interface name
172
+ # "todo.create" -> "TodoCreateArgs"
173
+ # "posts.bulk_update" -> "PostsBulkUpdateArgs"
174
+ def to_interface_name(mutation_name)
175
+ parts = mutation_name.to_s.split(".")
176
+ pascal = parts.map { |part| to_pascal_case(part) }.join
177
+ "#{pascal}Args"
178
+ end
179
+
180
+ # Convert snake_case to PascalCase
181
+ def to_pascal_case(str)
182
+ str.to_s.split("_").map(&:capitalize).join
183
+ end
184
+
185
+ # Convert snake_case to camelCase
186
+ def to_camel_case(name)
187
+ parts = name.to_s.split("_")
188
+ return parts.first if parts.length == 1
189
+ parts.first + parts[1..].map(&:capitalize).join
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRuby
4
+ # Base class for argument validators.
5
+ # Inspired by graphql-ruby's validation system.
6
+ class Validator
7
+ class << self
8
+ # Registry of validator classes by name
9
+ def validators
10
+ @validators ||= {}
11
+ end
12
+
13
+ # Register a validator class
14
+ def register(name, klass)
15
+ validators[name.to_sym] = klass
16
+ end
17
+
18
+ # Get a validator class by name
19
+ def get(name)
20
+ validators[name.to_sym]
21
+ end
22
+
23
+ # Run all validations on a value
24
+ # @param validators_config [Hash] Configuration hash for validators
25
+ # @param mutation [ZeroRuby::Mutation] The mutation instance
26
+ # @param ctx [Hash] The context hash
27
+ # @param value [Object] The value to validate
28
+ # @return [Array<String>] Array of error messages
29
+ def validate!(validators_config, mutation, ctx, value)
30
+ return [] if validators_config.nil? || validators_config.empty?
31
+
32
+ errors = []
33
+
34
+ validators_config.each do |validator_name, config|
35
+ validator_class = get(validator_name)
36
+ next unless validator_class
37
+
38
+ validator = validator_class.new(config)
39
+ result = validator.validate(mutation, ctx, value)
40
+ errors.concat(Array(result)) if result
41
+ end
42
+
43
+ errors
44
+ end
45
+ end
46
+
47
+ attr_reader :config
48
+
49
+ def initialize(config)
50
+ @config = config
51
+ end
52
+
53
+ # Validate a value
54
+ # @param mutation [ZeroRuby::Mutation] The mutation instance
55
+ # @param ctx [Hash] The context hash
56
+ # @param value [Object] The value to validate
57
+ # @return [String, Array<String>, nil] Error message(s) or nil if valid
58
+ def validate(mutation, ctx, value)
59
+ raise NotImplementedError, "Subclasses must implement #validate"
60
+ end
61
+
62
+ protected
63
+
64
+ # Helper to format error messages
65
+ def error_message(message)
66
+ message
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module ZeroRuby
6
+ module Validators
7
+ # Validates that a value is not blank when allow_blank is false.
8
+ #
9
+ # @example
10
+ # validates: { allow_blank: false }
11
+ class AllowBlankValidator < Validator
12
+ def validate(mutation, ctx, value)
13
+ # If allow_blank is true (or truthy), blank values are allowed
14
+ return nil if config == true || config
15
+
16
+ # Check if value is blank (nil, empty string, or whitespace-only string)
17
+ is_blank = value.nil? ||
18
+ (value.respond_to?(:empty?) && value.empty?) ||
19
+ (value.is_a?(::String) && value.strip.empty?)
20
+
21
+ if is_blank
22
+ return "can't be blank"
23
+ end
24
+
25
+ nil
26
+ end
27
+ end
28
+
29
+ Validator.register(:allow_blank, AllowBlankValidator)
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module ZeroRuby
6
+ module Validators
7
+ # Validates that a value is not null when allow_null is false.
8
+ #
9
+ # @example
10
+ # validates: { allow_null: false }
11
+ class AllowNullValidator < Validator
12
+ def validate(mutation, ctx, value)
13
+ # If allow_null is true (or truthy), null values are allowed
14
+ return nil if config == true || config
15
+
16
+ if value.nil?
17
+ return "can't be null"
18
+ end
19
+
20
+ nil
21
+ end
22
+ end
23
+
24
+ Validator.register(:allow_null, AllowNullValidator)
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module ZeroRuby
6
+ module Validators
7
+ # Validates that a value is NOT included in a given set.
8
+ #
9
+ # @example
10
+ # validates: { exclusion: { in: ["admin", "root", "system"] } }
11
+ class ExclusionValidator < Validator
12
+ def validate(mutation, ctx, value)
13
+ return nil if value.nil?
14
+
15
+ excluded = config[:in] || config[:within]
16
+ return nil unless excluded
17
+
18
+ if excluded.respond_to?(:include?) ? excluded.include?(value) : excluded.cover?(value)
19
+ message = config[:message] || "is reserved"
20
+ return message
21
+ end
22
+
23
+ nil
24
+ end
25
+ end
26
+
27
+ Validator.register(:exclusion, ExclusionValidator)
28
+ end
29
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module ZeroRuby
6
+ module Validators
7
+ # Validates that a value matches a regular expression.
8
+ #
9
+ # @example
10
+ # validates: { format: { with: /\A[a-z0-9_]+\z/ } }
11
+ # validates: { format: { without: /[<>]/ } }
12
+ class FormatValidator < Validator
13
+ def validate(mutation, ctx, value)
14
+ return nil if value.nil?
15
+
16
+ str_value = value.to_s
17
+ errors = []
18
+
19
+ if config[:with] && !str_value.match?(config[:with])
20
+ message = config[:message] || "is invalid"
21
+ errors << message
22
+ end
23
+
24
+ if config[:without] && str_value.match?(config[:without])
25
+ message = config[:message] || "is invalid"
26
+ errors << message
27
+ end
28
+
29
+ errors.empty? ? nil : errors
30
+ end
31
+ end
32
+
33
+ Validator.register(:format, FormatValidator)
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module ZeroRuby
6
+ module Validators
7
+ # Validates that a value is included in a given set.
8
+ #
9
+ # @example
10
+ # validates: { inclusion: { in: ["draft", "published", "archived"] } }
11
+ # validates: { inclusion: { in: 1..10 } }
12
+ class InclusionValidator < Validator
13
+ def validate(mutation, ctx, value)
14
+ return nil if value.nil?
15
+
16
+ allowed = config[:in] || config[:within]
17
+ return nil unless allowed
18
+
19
+ unless allowed.respond_to?(:include?) ? allowed.include?(value) : allowed.cover?(value)
20
+ message = config[:message] || "is not included in the list"
21
+ return message
22
+ end
23
+
24
+ nil
25
+ end
26
+ end
27
+
28
+ Validator.register(:inclusion, InclusionValidator)
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module ZeroRuby
6
+ module Validators
7
+ # Validates the length of a string or array value.
8
+ #
9
+ # @example
10
+ # validates: { length: { minimum: 1, maximum: 200 } }
11
+ # validates: { length: { is: 10 } }
12
+ # validates: { length: { in: 5..10 } }
13
+ class LengthValidator < Validator
14
+ def validate(mutation, ctx, value)
15
+ return nil if value.nil?
16
+
17
+ length = value.respond_to?(:length) ? value.length : value.to_s.length
18
+ errors = []
19
+
20
+ if config[:minimum] && length < config[:minimum]
21
+ errors << "is too short (minimum is #{config[:minimum]})"
22
+ end
23
+
24
+ if config[:maximum] && length > config[:maximum]
25
+ errors << "is too long (maximum is #{config[:maximum]})"
26
+ end
27
+
28
+ if config[:is] && length != config[:is]
29
+ errors << "is the wrong length (should be #{config[:is]})"
30
+ end
31
+
32
+ if config[:in] && !config[:in].cover?(length)
33
+ errors << "length is not in #{config[:in]}"
34
+ end
35
+
36
+ errors.empty? ? nil : errors
37
+ end
38
+ end
39
+
40
+ Validator.register(:length, LengthValidator)
41
+ end
42
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../validator"
4
+
5
+ module ZeroRuby
6
+ module Validators
7
+ # Validates numeric constraints on a value.
8
+ #
9
+ # @example
10
+ # validates: { numericality: { greater_than: 0 } }
11
+ # validates: { numericality: { less_than_or_equal_to: 100 } }
12
+ # validates: { numericality: { equal_to: 42 } }
13
+ # validates: { numericality: { odd: true } }
14
+ # validates: { numericality: { even: true } }
15
+ class NumericalityValidator < Validator
16
+ def validate(mutation, ctx, value)
17
+ return nil if value.nil?
18
+
19
+ unless value.is_a?(Numeric)
20
+ return "is not a number"
21
+ end
22
+
23
+ errors = []
24
+
25
+ if config[:greater_than] && value <= config[:greater_than]
26
+ errors << "must be greater than #{config[:greater_than]}"
27
+ end
28
+
29
+ if config[:greater_than_or_equal_to] && value < config[:greater_than_or_equal_to]
30
+ errors << "must be greater than or equal to #{config[:greater_than_or_equal_to]}"
31
+ end
32
+
33
+ if config[:less_than] && value >= config[:less_than]
34
+ errors << "must be less than #{config[:less_than]}"
35
+ end
36
+
37
+ if config[:less_than_or_equal_to] && value > config[:less_than_or_equal_to]
38
+ errors << "must be less than or equal to #{config[:less_than_or_equal_to]}"
39
+ end
40
+
41
+ if config[:equal_to] && value != config[:equal_to]
42
+ errors << "must be equal to #{config[:equal_to]}"
43
+ end
44
+
45
+ if config[:other_than] && value == config[:other_than]
46
+ errors << "must be other than #{config[:other_than]}"
47
+ end
48
+
49
+ if config[:odd] && value.to_i.even?
50
+ errors << "must be odd"
51
+ end
52
+
53
+ if config[:even] && value.to_i.odd?
54
+ errors << "must be even"
55
+ end
56
+
57
+ errors.empty? ? nil : errors
58
+ end
59
+ end
60
+
61
+ Validator.register(:numericality, NumericalityValidator)
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRuby
4
+ VERSION = "0.1.0.alpha1"
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRuby
4
+ # ZeroClient model for LMID (Last Mutation ID) tracking.
5
+ # This model interfaces with Zero's zero_0.clients table, which is
6
+ # automatically created and managed by zero-cache.
7
+ #
8
+ # Table Schema (created by zero-cache):
9
+ # clientGroupID TEXT - The client group identifier
10
+ # clientID TEXT - The client identifier
11
+ # lastMutationID INTEGER - The last processed mutation ID for this client
12
+ # userID TEXT - The user identifier (optional)
13
+ #
14
+ # @note Do NOT run migrations for this table - zero-cache manages it.
15
+ # @see https://zero.rocicorp.dev/docs/mutators
16
+ class ZeroClient < ActiveRecord::Base
17
+ self.table_name = "zero_0.clients"
18
+ self.primary_key = nil # Composite key: clientGroupID + clientID
19
+
20
+ def readonly?
21
+ # Allow updates through the LMID store's direct SQL
22
+ false
23
+ end
24
+ end
25
+ end