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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- 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
|
|
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:
|
|
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
|
|
108
|
-
|
|
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
|
|
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-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
289
|
-
@@request_counter += 1
|
|
290
|
-
end
|
|
301
|
+
self.class.next_request_id
|
|
291
302
|
end
|
|
292
303
|
end
|
|
293
304
|
end
|