parse-stack-next 4.5.0 → 5.0.1

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
@@ -0,0 +1,225 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "monitor"
5
+
6
+ module Parse
7
+ # Pluggable embedding-provider registry for `:vector` properties and
8
+ # the upcoming `find_similar(text:)` / `Parse::Retrieval.retrieve`
9
+ # surfaces.
10
+ #
11
+ # Text-only providers shipped:
12
+ #
13
+ # * {Fixture} — deterministic, zero-network. Auto-registered as
14
+ # `:fixture` so tests can call `Parse::Embeddings.provider(:fixture)`
15
+ # with no setup.
16
+ # * {OpenAI} — text-embedding-3-{small,large} and ada-002.
17
+ # * {Cohere} — embed-{english,multilingual}-v3.0 and `*-light-v3.0`.
18
+ # Distinguishes `:search_query` / `:search_document` at the wire.
19
+ # * {Voyage} — voyage-4 family (incl. open-weight `voyage-4-nano`),
20
+ # voyage-3 family, voyage-code-3, voyage-finance-2, voyage-law-2.
21
+ # Distinguishes input types.
22
+ # * {Jina} — jina-embeddings-v3/v4/v5 (text + omni-text mode),
23
+ # jina-code-embeddings-{0.5b,1.5b}. Matryoshka via `dimensions:`.
24
+ # * {Qwen} — qwen3-embedding-{0.6b,4b,8b} via Alibaba Cloud
25
+ # DashScope compatible-mode. All Matryoshka. The same checkpoints
26
+ # are open-weight on Hugging Face (Apache 2.0) for self-hosting
27
+ # behind {LocalHTTP}.
28
+ # * {LocalHTTP} — generic OpenAI-compatible client for Ollama,
29
+ # LM Studio, vLLM, etc. Configure-time SSRF gate; requires
30
+ # `allow_private_endpoint: true` to talk to localhost.
31
+ #
32
+ # Image / multimodal embedding (`embed_image`) is a forthcoming
33
+ # feature — the {Provider#embed_image} hook is defined but only the
34
+ # multimodal-capable providers will override it.
35
+ #
36
+ # == Registration
37
+ #
38
+ # Two equivalent forms. {.register} is the canonical one-liner and
39
+ # what every example in the gem uses; {.configure} is the block form
40
+ # for registering several providers at once or for Rails-style
41
+ # initializers. Both end up at the same {ProviderRegistry}, so pick
42
+ # whichever reads better in context.
43
+ #
44
+ # @example canonical: register one provider
45
+ # Parse::Embeddings.register(:openai,
46
+ # Parse::Embeddings::OpenAI.new(api_key: ENV.fetch("OPENAI_API_KEY")))
47
+ #
48
+ # @example block form for several providers
49
+ # Parse::Embeddings.configure do |c|
50
+ # c.providers[:openai] = Parse::Embeddings::OpenAI.new(api_key: ENV.fetch("OPENAI_API_KEY"))
51
+ # c.providers[:openai_large] = Parse::Embeddings::OpenAI.new(
52
+ # api_key: ENV.fetch("OPENAI_API_KEY"), model: "text-embedding-3-large")
53
+ # end
54
+ #
55
+ # @example lookup
56
+ # Parse::Embeddings.provider(:openai) # => the registered instance
57
+ # Parse::Embeddings.provider(:fixture) # => default Fixture, zero-config
58
+ module Embeddings
59
+ # Common superclass for every embeddings-layer exception. Concrete
60
+ # providers (OpenAI, Cohere, Voyage, …) should raise subclasses of
61
+ # this so retry middleware and caller `rescue` chains have a single
62
+ # target. Inherits from {StandardError}, not {Parse::Error}, because
63
+ # embedding providers are external HTTP boundaries — their failures
64
+ # are distinct from Parse Server protocol errors.
65
+ class Error < StandardError; end
66
+
67
+ # Raised when a provider returns a response that doesn't satisfy the
68
+ # contract (wrong length, NaN, ±Inf, non-Array, wrong-width vector,
69
+ # non-Float/Integer elements). See {Provider#validate_response!}.
70
+ class InvalidResponseError < Error; end
71
+
72
+ # Raised when {Embeddings.provider} is called with an unknown name
73
+ # and no built-in default exists for that key. Remains an
74
+ # {ArgumentError} (not an {Error}) so config-time mistakes are
75
+ # distinguishable from runtime provider failures.
76
+ class ProviderNotRegistered < ArgumentError; end
77
+ end
78
+ end
79
+
80
+ # Provider must load before OpenAI (which references it as superclass)
81
+ # and before ProviderRegistry below (which type-checks against it).
82
+ require_relative "embeddings/provider"
83
+
84
+ module Parse
85
+ module Embeddings
86
+ # Hash subclass that enforces {Provider} membership at assignment
87
+ # time. Without this, `configuration.providers[:openai] = "anything"`
88
+ # would silently bypass {Embeddings.register}'s type-check and let a
89
+ # duck-typed object skip {Provider#validate_response!} — defeating
90
+ # the whole boundary contract.
91
+ class ProviderRegistry < Hash
92
+ def []=(name, provider)
93
+ unless provider.is_a?(Provider)
94
+ raise ArgumentError,
95
+ "Parse::Embeddings::ProviderRegistry: #{name.inspect} expects a " \
96
+ "Parse::Embeddings::Provider instance (got #{provider.class})."
97
+ end
98
+ super(name.to_sym, provider)
99
+ end
100
+ alias_method :store, :[]=
101
+ end
102
+
103
+ # Configuration container yielded to {Embeddings.configure}.
104
+ class Configuration
105
+ # @return [ProviderRegistry] type-checked provider registry.
106
+ attr_reader :providers
107
+
108
+ def initialize
109
+ @providers = ProviderRegistry.new
110
+ end
111
+ end
112
+
113
+ # Monitor guarding {Embeddings.configuration} memoization and
114
+ # {Embeddings.register} writes. MRI's GVL would normally absorb
115
+ # the race on `@configuration ||= ...`, but JRuby and TruffleRuby
116
+ # can produce two `Configuration` instances when two threads race
117
+ # at boot (and lose any provider written to the loser). A Monitor
118
+ # (rather than a Mutex) is used so that `register` — which holds
119
+ # the lock and then calls `configuration` — can re-enter without
120
+ # deadlocking on the first-touch allocation path.
121
+ CONFIG_MUTEX = Monitor.new
122
+
123
+ class << self
124
+ # Block form for registering multiple providers at once. Prefer
125
+ # the one-liner {.register} when adding a single provider; this
126
+ # form pays off when an initializer needs to set several or to
127
+ # mutate the registry conditionally.
128
+ #
129
+ # @yieldparam config [Configuration]
130
+ # @return [Configuration]
131
+ def configure
132
+ yield configuration if block_given?
133
+ configuration
134
+ end
135
+
136
+ # @return [Configuration] the singleton configuration object.
137
+ def configuration
138
+ # Double-checked memoization. The fast path is a single ivar
139
+ # read; the slow path enters the mutex only when the
140
+ # configuration is unallocated.
141
+ @configuration || CONFIG_MUTEX.synchronize { @configuration ||= Configuration.new }
142
+ end
143
+
144
+ # Canonical one-liner: register a single provider under `name`.
145
+ # Overwrites any previous registration. Use {.configure} for
146
+ # multi-provider blocks.
147
+ #
148
+ # @param name [Symbol, String]
149
+ # @param provider [Provider]
150
+ # @return [Provider] the registered provider.
151
+ def register(name, provider)
152
+ unless provider.is_a?(Provider)
153
+ raise ArgumentError,
154
+ "Parse::Embeddings.register: #{name.inspect} expects a Parse::Embeddings::Provider " \
155
+ "instance (got #{provider.class})."
156
+ end
157
+ CONFIG_MUTEX.synchronize do
158
+ configuration.providers[name.to_sym] = provider
159
+ end
160
+ end
161
+
162
+ # Look up a registered provider.
163
+ #
164
+ # **Zero-config fallback:** `:fixture` returns a default
165
+ # {Fixture} instance (64-dim, deterministic) when nothing is
166
+ # registered. Every other name raises {ProviderNotRegistered}.
167
+ # Tests can rely on `provider(:fixture)` working out of the box;
168
+ # production code must register what it uses.
169
+ #
170
+ # @param name [Symbol, String]
171
+ # @return [Provider]
172
+ # @raise [ProviderNotRegistered] when the name is unknown.
173
+ def provider(name)
174
+ # Avoid blindly `to_sym`-ing the caller's input. An LLM tool or
175
+ # webhook handler that pipes its `name:` argument through here
176
+ # would otherwise let a remote caller grow the symbol table at
177
+ # will. Ruby 3.2+ GCs symbols so the practical impact is small,
178
+ # but a string-matched lookup costs nothing and closes the gap.
179
+ if name.is_a?(Symbol)
180
+ return configuration.providers[name] if configuration.providers.key?(name)
181
+ key_string = name.to_s
182
+ else
183
+ key_string = name.to_s
184
+ found = configuration.providers.keys.find { |k| k.to_s == key_string }
185
+ return configuration.providers[found] if found
186
+ end
187
+ if key_string == "fixture"
188
+ CONFIG_MUTEX.synchronize do
189
+ return configuration.providers[:fixture] ||= Fixture.new
190
+ end
191
+ end
192
+ raise ProviderNotRegistered,
193
+ "Parse::Embeddings.provider(#{name.inspect}): no provider registered. " \
194
+ "Register one via Parse::Embeddings.register(#{name.inspect}, …)."
195
+ end
196
+
197
+ # Names of currently-registered providers (does NOT include the
198
+ # implicit `:fixture` fallback unless it's been instantiated).
199
+ #
200
+ # @return [Array<Symbol>]
201
+ def registered_provider_names
202
+ configuration.providers.keys
203
+ end
204
+
205
+ # Reset the entire registry — intended for test teardown only.
206
+ # Production code should never call this; use {.register} to
207
+ # override a single provider.
208
+ #
209
+ # @return [void]
210
+ def reset!
211
+ CONFIG_MUTEX.synchronize { @configuration = nil }
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ # Concrete providers — loaded after Error / Provider / ProviderRegistry
218
+ # so their class bodies can reference those constants.
219
+ require_relative "embeddings/fixture"
220
+ require_relative "embeddings/openai"
221
+ require_relative "embeddings/cohere"
222
+ require_relative "embeddings/voyage"
223
+ require_relative "embeddings/jina"
224
+ require_relative "embeddings/qwen"
225
+ require_relative "embeddings/local_http"
@@ -0,0 +1,53 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Shared GraphQL types for Parse-native shapes (File, GeoPoint) and a
5
+ # JSON scalar fallback for `:array` / `:object` columns that have no
6
+ # declared element type.
7
+ #
8
+ # Object types are preferred over scalars wherever the shape is
9
+ # stable and small: graphql-ruby's own Scalars guide explicitly warns
10
+ # that JSON scalars "completely lose all GraphQL type safety". File and
11
+ # GeoPoint both qualify — fixed two-field shapes that may grow
12
+ # additively (e.g. `content_type`, `size`) without a breaking change.
13
+ #
14
+ # Loaded only when the `graphql` gem is available — guarded by
15
+ # `Parse::GraphQL.available?` in `lib/parse/graphql.rb`.
16
+
17
+ module Parse
18
+ module GraphQL
19
+ module Types
20
+ class ParseFile < ::GraphQL::Schema::Object
21
+ graphql_name "ParseFile"
22
+ description "A Parse File reference (URL + filename)."
23
+ field :url, String, null: false
24
+ field :name, String, null: true
25
+ end
26
+
27
+ class ParseGeoPoint < ::GraphQL::Schema::Object
28
+ graphql_name "ParseGeoPoint"
29
+ description "A Parse GeoPoint (latitude/longitude in degrees, WGS84)."
30
+ field :latitude, Float, null: false
31
+ field :longitude, Float, null: false
32
+ end
33
+
34
+ # Fallback for `:array` / `:object` columns with no element type.
35
+ # Emits a warning at codegen time when used so authors know to
36
+ # narrow the type if possible. Subscribers needing typed list
37
+ # elements should declare `belongs_to` / `has_many` instead of
38
+ # a raw `property :foo, :array`.
39
+ class JSON < ::GraphQL::Schema::Scalar
40
+ graphql_name "JSON"
41
+ description "Arbitrary JSON value (object, array, scalar, or null)."
42
+
43
+ def self.coerce_input(value, _ctx)
44
+ value
45
+ end
46
+
47
+ def self.coerce_result(value, _ctx)
48
+ value
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,264 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module GraphQL
6
+ # Generates `GraphQL::Schema::Object` subclasses from Parse::Object
7
+ # subclasses by reading the class-level property and association
8
+ # registries: `fields`, `field_map`, `references` (belongs_to),
9
+ # `has_one_associations`, `has_many_associations`, and `relations`.
10
+ #
11
+ # v1 scope: type shape only. Default graphql-ruby field resolution
12
+ # invokes the same-named method on the underlying Ruby object, and
13
+ # Parse::Object subclasses already expose typed accessors
14
+ # (`song.album`, `band.fans`, etc.) — so no resolvers are emitted.
15
+ # Pagination arguments, Loaders, and Relay connections are deferred
16
+ # until query/mutation passthrough lands (TODO §7).
17
+ #
18
+ # Cross-class references (pointers, has_many) require all referenced
19
+ # model types to be in the registry BEFORE field emission. Use
20
+ # `generate_all` to codegen a list of models in one call (two-pass:
21
+ # stub classes first, then add fields), or `generate` with an
22
+ # explicit `registry:` hash if you want incremental control.
23
+ class TypeGenerator
24
+ # Maps Parse property `:type` symbols to graphql-ruby built-ins.
25
+ # Cross-class references (`:pointer`, `:relation`) are NOT in this
26
+ # map — they need the target class and are handled per-field.
27
+ # Nil mappings fall through to the JSON scalar with a warning.
28
+ SCALAR_TYPE_MAP = {
29
+ string: ::GraphQL::Types::String,
30
+ integer: ::GraphQL::Types::Int,
31
+ float: ::GraphQL::Types::Float,
32
+ boolean: ::GraphQL::Types::Boolean,
33
+ date: ::GraphQL::Types::ISO8601DateTime,
34
+ timezone: ::GraphQL::Types::String,
35
+ phone: ::GraphQL::Types::String,
36
+ email: ::GraphQL::Types::String,
37
+ # Parse Bytes is a `{__type: Bytes, base64: ...}` wrapper, not a
38
+ # bare string — the accessor returns the wrapped object, so
39
+ # falling through to the JSON scalar (with a warn) is safer than
40
+ # ::String.to_s on the hash. Subscribers needing structured byte
41
+ # access should declare a `:string` property containing the base64.
42
+ bytes: nil,
43
+ polygon: nil, # JSON fallback (GeoJSON-shaped, multi-ring)
44
+ # Parse vector columns are bounded-length Float arrays
45
+ # (embeddings). Emit as a typed list of Floats, not JSON.
46
+ vector: [::GraphQL::Types::Float],
47
+ array: nil, # JSON fallback (no element type known)
48
+ object: nil, # JSON fallback
49
+ }.freeze
50
+
51
+ # Property types that are deliberately omitted. ACL is internal
52
+ # authz metadata; exposing it via GraphQL would leak authorization
53
+ # shape into clients. The objectId is exposed under the canonical
54
+ # `id: ID` field separately.
55
+ OMITTED_TYPES = %i[acl].freeze
56
+
57
+ # Generate types for a list of models in one call. Two-pass:
58
+ # creates empty stub classes for every model first so cross-class
59
+ # references resolve regardless of declaration order.
60
+ #
61
+ # @param model_classes [Array<Class>] Parse::Object subclasses.
62
+ # @return [Hash{String => Class}] registry of generated types
63
+ # keyed by Parse class name.
64
+ def self.generate_all(model_classes)
65
+ registry = {}
66
+ # Pass 1: stub classes registered by parse_class name.
67
+ model_classes.each do |model|
68
+ validate!(model)
69
+ registry[model.parse_class] = build_stub(model)
70
+ end
71
+ # Pass 2: populate fields. Cross-references now resolve.
72
+ model_classes.each do |model|
73
+ new(model, registry: registry, _prebuilt: registry[model.parse_class]).populate_fields
74
+ end
75
+ detect_name_collisions!(registry)
76
+ registry
77
+ end
78
+
79
+ # graphql-ruby requires unique `graphql_name` across the schema.
80
+ # `build_stub` strips underscores so `_User` and `User` collapse
81
+ # to the same name. Raise a clear error rather than letting
82
+ # graphql-ruby's `DuplicateNamesError` surface at schema-build
83
+ # time, which doesn't say which Parse classes collided.
84
+ def self.detect_name_collisions!(registry)
85
+ by_gql_name = registry.each_with_object({}) do |(parse_name, type), acc|
86
+ (acc[type.graphql_name] ||= []) << parse_name
87
+ end
88
+ collisions = by_gql_name.select { |_, names| names.size > 1 }
89
+ return if collisions.empty?
90
+ details = collisions.map { |gql, parse| "#{gql} ← #{parse.join(', ')}" }.join('; ')
91
+ raise "Parse::GraphQL::TypeGenerator: graphql_name collisions: #{details}. " \
92
+ "Parse class names that differ only by underscores collapse to the same " \
93
+ "GraphQL type name. Rename or generate the conflicting classes separately."
94
+ end
95
+
96
+ # Generate a single type. If the model has belongs_to / has_many
97
+ # references to other Parse classes, those targets must already be
98
+ # in the registry — otherwise an error is raised at field-emit
99
+ # time. Prefer `generate_all` when you have a graph of models.
100
+ #
101
+ # @param model_class [Class] a Parse::Object subclass.
102
+ # @param registry [Hash, nil] shared registry; mutated in place.
103
+ # @return [Class] anonymous `GraphQL::Schema::Object` subclass.
104
+ def self.generate(model_class, registry: nil)
105
+ validate!(model_class)
106
+ registry ||= {}
107
+ stub = registry[model_class.parse_class] ||= build_stub(model_class)
108
+ new(model_class, registry: registry, _prebuilt: stub).populate_fields
109
+ stub
110
+ end
111
+
112
+ def self.validate!(model_class)
113
+ unless model_class.is_a?(Class) && model_class < Parse::Object
114
+ raise ArgumentError,
115
+ "Parse::GraphQL::TypeGenerator requires a Parse::Object subclass, got #{model_class.inspect}"
116
+ end
117
+ end
118
+
119
+ def self.build_stub(model_class)
120
+ type_name = model_class.parse_class.tr("_", "")
121
+ description_text = "Generated GraphQL type for Parse class #{model_class.parse_class}."
122
+ Class.new(::GraphQL::Schema::Object) do
123
+ graphql_name type_name
124
+ description description_text
125
+ end
126
+ end
127
+
128
+ def initialize(model_class, registry:, _prebuilt:)
129
+ @model_class = model_class
130
+ @registry = registry
131
+ @klass = _prebuilt
132
+ end
133
+
134
+ def populate_fields
135
+ emit_scalar_fields
136
+ emit_belongs_to_fields
137
+ emit_has_one_fields
138
+ emit_has_many_fields
139
+ @klass
140
+ end
141
+
142
+ private
143
+
144
+ # Iterate over local accessor names from field_map so we never
145
+ # register the same field twice (Parse stores both local and
146
+ # remote names in `fields`).
147
+ def emit_scalar_fields
148
+ skip_keys = pointer_field_keys | relation_field_keys | array_assoc_field_keys
149
+ emitted = {}
150
+
151
+ @model_class.field_map.each do |local_key, _remote_field|
152
+ next if skip_keys.include?(local_key)
153
+ parse_type = @model_class.fields[local_key]
154
+ next if parse_type.nil?
155
+ next if OMITTED_TYPES.include?(parse_type)
156
+
157
+ gql_type = scalar_graphql_type(parse_type, local_key)
158
+ next if gql_type.nil?
159
+
160
+ # objectId → expose as canonical `id: ID!` per GraphQL idiom.
161
+ if local_key == :id
162
+ next if emitted[:id]
163
+ @klass.field :id, ::GraphQL::Types::ID, null: false
164
+ emitted[:id] = true
165
+ else
166
+ next if emitted[local_key]
167
+ @klass.field(local_key, gql_type, null: true)
168
+ emitted[local_key] = true
169
+ end
170
+ end
171
+ end
172
+
173
+ def emit_belongs_to_fields
174
+ @model_class.references.each do |parse_field, target_class_name|
175
+ accessor = field_to_accessor(parse_field)
176
+ target = registry_lookup!(target_class_name)
177
+ @klass.field(accessor, target, null: true)
178
+ end
179
+ end
180
+
181
+ def emit_has_one_fields
182
+ return unless @model_class.respond_to?(:has_one_associations)
183
+ @model_class.has_one_associations.each do |name, meta|
184
+ target = registry_lookup!(meta[:target_class])
185
+ @klass.field(name, target, null: true)
186
+ end
187
+ end
188
+
189
+ def emit_has_many_fields
190
+ return unless @model_class.respond_to?(:has_many_associations)
191
+ @model_class.has_many_associations.each do |name, meta|
192
+ target = registry_lookup!(meta[:target_class])
193
+ @klass.field(name, [target], null: true)
194
+ end
195
+ end
196
+
197
+ # -----------------------------------------------------------------
198
+ # helpers
199
+ # -----------------------------------------------------------------
200
+
201
+ def scalar_graphql_type(parse_type, field_name)
202
+ case parse_type
203
+ when :file then Parse::GraphQL::Types::ParseFile
204
+ when :geopoint then Parse::GraphQL::Types::ParseGeoPoint
205
+ when :pointer, :relation
206
+ nil # handled by emit_belongs_to_fields / emit_has_many_fields
207
+ else
208
+ mapped = SCALAR_TYPE_MAP[parse_type]
209
+ return mapped unless mapped.nil?
210
+ # nil mapping → JSON fallback. Warn so authors know to narrow.
211
+ warn "[Parse::GraphQL] #{@model_class.parse_class}.#{field_name}: " \
212
+ "Parse type #{parse_type.inspect} has no specific GraphQL " \
213
+ "type; emitting as JSON scalar."
214
+ Parse::GraphQL::Types::JSON
215
+ end
216
+ end
217
+
218
+ def registry_lookup!(target_class_name)
219
+ target = @registry[target_class_name]
220
+ if target.nil?
221
+ raise "Parse::GraphQL::TypeGenerator: no generated type found for " \
222
+ "#{target_class_name.inspect}. Call generate_all with all " \
223
+ "referenced models, or generate #{target_class_name} first " \
224
+ "into the shared `registry:` hash."
225
+ end
226
+ target
227
+ end
228
+
229
+ def pointer_field_keys
230
+ return @pointer_field_keys if defined?(@pointer_field_keys)
231
+ # references stores parse_field => target. Map to local accessors.
232
+ @pointer_field_keys = @model_class.references.keys.map do |parse_field|
233
+ field_to_accessor(parse_field)
234
+ end.to_set
235
+ end
236
+
237
+ def relation_field_keys
238
+ @relation_field_keys ||= @model_class.relations.keys.map(&:to_sym).to_set
239
+ end
240
+
241
+ # `has_many through: :array` stores the field as `:array` in
242
+ # `fields` — exclude it from scalar emission so it's not emitted
243
+ # twice (once as JSON, once as `[Type]`).
244
+ def array_assoc_field_keys
245
+ return @array_assoc_field_keys if defined?(@array_assoc_field_keys)
246
+ keys = []
247
+ if @model_class.respond_to?(:has_many_associations)
248
+ @model_class.has_many_associations.each_value do |meta|
249
+ keys << meta[:field] if meta[:storage] == :array && meta[:field]
250
+ end
251
+ end
252
+ @array_assoc_field_keys = keys.map(&:to_sym).to_set
253
+ end
254
+
255
+ # Reverse field_map: parse_field (remote) → local accessor symbol.
256
+ def field_to_accessor(parse_field)
257
+ @model_class.field_map.each do |local, remote|
258
+ return local if remote.to_sym == parse_field.to_sym
259
+ end
260
+ parse_field.to_sym
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,48 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Parse::GraphQL — schema introspection → graphql-ruby type generation.
5
+ #
6
+ # The `graphql` gem is an OPTIONAL dependency. It is intentionally NOT a
7
+ # runtime dependency of parse-stack: users who never opt into GraphQL
8
+ # codegen should not pay the load cost. Mirror the
9
+ # `Parse::MongoDB.gem_available?` soft-require pattern.
10
+ #
11
+ # Usage:
12
+ # require "parse/graphql"
13
+ # raise "install the 'graphql' gem first" unless Parse::GraphQL.available?
14
+ # SongType = Parse::GraphQL::TypeGenerator.generate(Song)
15
+ #
16
+ # v1 scope: type generation only. Resolvers (query/mutation passthrough,
17
+ # Loaders, Node interface, connections) are intentionally deferred — see
18
+ # TODO.md §7. The generated types work with default graphql-ruby field
19
+ # resolution because Parse::Object subclasses already expose typed
20
+ # accessors (`song.album`, `band.fans`).
21
+
22
+ module Parse
23
+ module GraphQL
24
+ class << self
25
+ # @return [Boolean] true if the `graphql` gem can be loaded.
26
+ def available?
27
+ return @gem_available if defined?(@gem_available)
28
+ @gem_available = begin
29
+ require "graphql"
30
+ true
31
+ rescue LoadError
32
+ false
33
+ end
34
+ end
35
+
36
+ # Force-reset the cached availability flag. Test-only.
37
+ # @!visibility private
38
+ def reset!
39
+ remove_instance_variable(:@gem_available) if defined?(@gem_available)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ if Parse::GraphQL.available?
46
+ require_relative "graphql/scalars"
47
+ require_relative "graphql/type_generator"
48
+ end
@@ -87,14 +87,29 @@ module Parse
87
87
  # @return [Integer] frame read timeout in seconds
88
88
  attr_reader :frame_read_timeout
89
89
 
90
+ # Sentinel used to distinguish "caller passed nothing" from
91
+ # "caller explicitly passed nil". The latter must NOT fall through
92
+ # to the LiveQuery configuration or the parent Parse client value
93
+ # — that fallthrough was a silent master-key-smuggling bug for
94
+ # client-mode (no-master) callers. Aliased to the top-level
95
+ # {Parse::NOT_PROVIDED} so any other SDK code that needs the same
96
+ # "omitted vs nil" distinction reuses one identity-comparable
97
+ # constant.
98
+ NOT_PROVIDED = Parse::NOT_PROVIDED
99
+ private_constant :NOT_PROVIDED
100
+
90
101
  # Create a new LiveQuery client
91
102
  # @param url [String] WebSocket URL (wss://...)
92
103
  # @param application_id [String] Parse application ID
93
104
  # @param client_key [String] Parse REST API key
94
- # @param master_key [String, nil] Parse master key (optional)
105
+ # @param master_key [String, nil] Parse master key. Pass `nil`
106
+ # explicitly to force client-mode (no master key on the
107
+ # subscription handshake) even when the parent Parse client has
108
+ # one. Omit the argument to fall back to the LiveQuery config
109
+ # or the parent Parse client.
95
110
  # @param auto_connect [Boolean] connect immediately (default: true)
96
111
  # @param auto_reconnect [Boolean] automatically reconnect on disconnect (default: true)
97
- def initialize(url: nil, application_id: nil, client_key: nil, master_key: nil,
112
+ def initialize(url: nil, application_id: nil, client_key: nil, master_key: NOT_PROVIDED,
98
113
  auto_connect: nil, auto_reconnect: nil)
99
114
  cfg = config
100
115
 
@@ -104,8 +119,11 @@ module Parse
104
119
  parse_client_value(:application_id)
105
120
  @client_key = client_key || cfg.client_key ||
106
121
  parse_client_value(:api_key)
107
- @master_key = master_key || cfg.master_key ||
108
- parse_client_value(:master_key)
122
+ @master_key = if master_key.equal?(NOT_PROVIDED)
123
+ cfg.master_key || parse_client_value(:master_key)
124
+ else
125
+ master_key
126
+ end
109
127
 
110
128
  @auto_connect = auto_connect.nil? ? cfg.auto_connect : auto_connect
111
129
  @auto_reconnect = auto_reconnect.nil? ? cfg.auto_reconnect : auto_reconnect
@@ -787,11 +805,12 @@ module Parse
787
805
  # Dispatch event to subscription (called from event queue processor)
788
806
  # @param item [Hash] contains :subscription and :event
789
807
  def dispatch_event(item)
808
+ event = nil
790
809
  subscription = item[:subscription]
791
810
  event = item[:event]
792
811
  subscription.handle_event(event)
793
812
  rescue => e
794
- Logging.error("Event dispatch error", error: e, event_type: event.type)
813
+ Logging.error("Event dispatch error", error: e, event_type: event&.type)
795
814
  end
796
815
 
797
816
  # Handle server error
@@ -33,9 +33,22 @@ module Parse
33
33
  # subscription.unsubscribe
34
34
  #
35
35
  class Subscription
36
- # Class-level monitor for request ID generation
37
- @@id_monitor = Monitor.new
38
- @@request_counter = 0
36
+ # Class-instance state (rather than @@class_var) for request ID generation.
37
+ # Held on the Subscription singleton and guarded by a Monitor so concurrent
38
+ # subscriptions across threads cannot tear the counter. See
39
+ # Parse::ACLScope's `@no_acl_warned` pattern for the same convention.
40
+ @id_monitor = Monitor.new
41
+ @request_counter = 0
42
+
43
+ class << self
44
+ # @!visibility private
45
+ attr_reader :id_monitor
46
+
47
+ # @!visibility private
48
+ def next_request_id
49
+ @id_monitor.synchronize { @request_counter += 1 }
50
+ end
51
+ end
39
52
 
40
53
  # @return [Integer] unique request ID for this subscription
41
54
  attr_reader :request_id
@@ -285,9 +298,7 @@ module Parse
285
298
  # Generate a unique request ID (thread-safe)
286
299
  # @return [Integer]
287
300
  def generate_request_id
288
- @@id_monitor.synchronize do
289
- @@request_counter += 1
290
- end
301
+ self.class.next_request_id
291
302
  end
292
303
  end
293
304
  end