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
data/lib/parse/stack.rb
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# encoding: UTF-8
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "uri"
|
|
6
|
-
|
|
7
4
|
require_relative "stack/version"
|
|
8
5
|
require_relative "client"
|
|
9
6
|
require_relative "query"
|
|
@@ -16,6 +13,7 @@ require_relative "schema"
|
|
|
16
13
|
require_relative "schema/index_migrator"
|
|
17
14
|
require_relative "schema/search_index_migrator"
|
|
18
15
|
require_relative "lookup_rewriter"
|
|
16
|
+
require_relative "console"
|
|
19
17
|
|
|
20
18
|
module Parse
|
|
21
19
|
class Error < StandardError; end
|
|
@@ -23,6 +21,27 @@ module Parse
|
|
|
23
21
|
module Stack
|
|
24
22
|
end
|
|
25
23
|
|
|
24
|
+
# Sentinel used by SDK methods that need to distinguish "the caller
|
|
25
|
+
# omitted this kwarg" from "the caller explicitly passed `nil`" —
|
|
26
|
+
# the latter must NOT fall through to a default that would silently
|
|
27
|
+
# re-introduce a value the caller is trying to suppress (e.g. a
|
|
28
|
+
# master-key or session-token override).
|
|
29
|
+
#
|
|
30
|
+
# Use as the default value of a keyword argument, then check with
|
|
31
|
+
# `value.equal?(Parse::NOT_PROVIDED)` to detect omission. Comparison
|
|
32
|
+
# by identity is intentional — `==` on the sentinel is meaningless.
|
|
33
|
+
#
|
|
34
|
+
# @example Distinguishing nil-pass from omission
|
|
35
|
+
# def fetch(master_key: Parse::NOT_PROVIDED)
|
|
36
|
+
# resolved = master_key.equal?(Parse::NOT_PROVIDED) ? config.master_key : master_key
|
|
37
|
+
# # `fetch(master_key: nil)` here produces `nil`, not the config value
|
|
38
|
+
# end
|
|
39
|
+
NOT_PROVIDED = Object.new.tap do |o|
|
|
40
|
+
def o.inspect
|
|
41
|
+
"Parse::NOT_PROVIDED"
|
|
42
|
+
end
|
|
43
|
+
end.freeze
|
|
44
|
+
|
|
26
45
|
# Fiber-local key consulted by the authentication middleware. A truthy
|
|
27
46
|
# entry suppresses the master-key header for the duration of the block
|
|
28
47
|
# set by {Parse.without_master_key}; a +:enabled+ entry forces the
|
|
@@ -80,6 +99,199 @@ module Parse
|
|
|
80
99
|
Fiber[MASTER_KEY_STATE_KEY] == :disabled
|
|
81
100
|
end
|
|
82
101
|
|
|
102
|
+
# Fiber-local key holding the ambient session token consulted by
|
|
103
|
+
# {Parse::Client#request} when no explicit `session_token:` was
|
|
104
|
+
# passed. Set by {Parse.with_session}; nested blocks save and restore
|
|
105
|
+
# the previous value on exit.
|
|
106
|
+
SESSION_TOKEN_STATE_KEY = :__parse_session_token__
|
|
107
|
+
|
|
108
|
+
# Run +block+ with an ambient session token set for the current fiber.
|
|
109
|
+
# Inside the block, every Parse request that doesn't explicitly pass
|
|
110
|
+
# `session_token:` *and* doesn't explicitly request `use_master_key:
|
|
111
|
+
# true` will be sent with this token. Equivalent to threading
|
|
112
|
+
# `session_token:` through every call site, but block-scoped.
|
|
113
|
+
#
|
|
114
|
+
# The `token` argument may be a String, a {Parse::User} (its
|
|
115
|
+
# `session_token` is read), a {Parse::Session} (its `session_token` is
|
|
116
|
+
# read), or `nil`. Passing `nil` clears the ambient inside the block —
|
|
117
|
+
# useful for performing one anonymous call inside an otherwise
|
|
118
|
+
# session-scoped region.
|
|
119
|
+
#
|
|
120
|
+
# Fiber-local, not thread-local: concurrent fibers (and threads, since
|
|
121
|
+
# each thread starts with its own root fiber) do not share state.
|
|
122
|
+
# Survives Faraday retries — the token lives for the lifetime of the
|
|
123
|
+
# block, not just the first HTTP attempt.
|
|
124
|
+
#
|
|
125
|
+
# An explicit `session_token:` kwarg on any call still wins over the
|
|
126
|
+
# ambient. An explicit `use_master_key: true` skips the ambient and
|
|
127
|
+
# sends the master key (if configured).
|
|
128
|
+
#
|
|
129
|
+
# @param token [String, Parse::User, Parse::Session, nil]
|
|
130
|
+
# @yield runs the block with the ambient session token in place
|
|
131
|
+
# @return [Object] the block's return value
|
|
132
|
+
# @example
|
|
133
|
+
# user = Parse::User.login!("alice", "pw")
|
|
134
|
+
# Parse.with_session(user) do
|
|
135
|
+
# post = Post.find(id) # scoped to alice
|
|
136
|
+
# post.title = "edited"
|
|
137
|
+
# post.save # subject to ACL/CLP
|
|
138
|
+
# Comment.all(post: post) # scoped to alice
|
|
139
|
+
# end
|
|
140
|
+
def self.with_session(token)
|
|
141
|
+
resolved = token.respond_to?(:session_token) ? token.session_token : token
|
|
142
|
+
resolved = resolved.to_s if resolved
|
|
143
|
+
previous = Fiber[SESSION_TOKEN_STATE_KEY]
|
|
144
|
+
Fiber[SESSION_TOKEN_STATE_KEY] = (resolved && !resolved.empty?) ? resolved : nil
|
|
145
|
+
yield
|
|
146
|
+
ensure
|
|
147
|
+
Fiber[SESSION_TOKEN_STATE_KEY] = previous
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The ambient session token set by {.with_session} for the current
|
|
151
|
+
# fiber, or `nil` when not inside such a block.
|
|
152
|
+
# @return [String, nil]
|
|
153
|
+
def self.current_session_token
|
|
154
|
+
Fiber[SESSION_TOKEN_STATE_KEY]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The {Parse::User} cached alongside the ambient session set by
|
|
158
|
+
# {.login}, or `nil` when no imperative login is active. Block-scoped
|
|
159
|
+
# `{Parse.with_session}` does NOT populate this — only {.login} does.
|
|
160
|
+
# @return [Parse::User, nil]
|
|
161
|
+
def self.current_user
|
|
162
|
+
Fiber[CURRENT_USER_STATE_KEY]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Fiber-local key holding the {Parse::User} cached by {.login} for
|
|
166
|
+
# {.current_user} lookup. Kept distinct from the session-token key so
|
|
167
|
+
# block-scoped `Parse.with_session(tok)` (which has only a token, not a
|
|
168
|
+
# user object) doesn't mis-populate it.
|
|
169
|
+
CURRENT_USER_STATE_KEY = :__parse_current_user__
|
|
170
|
+
|
|
171
|
+
# Imperative login for REPL / Rake-console use: logs in once, stashes
|
|
172
|
+
# the resulting session token as the ambient for the current fiber,
|
|
173
|
+
# and returns the {Parse::User}. Every subsequent Parse call in the
|
|
174
|
+
# session (the IRB main fiber) is then auth-scoped to that user
|
|
175
|
+
# without the caller threading `session_token:` or wrapping each
|
|
176
|
+
# statement in {.with_session}.
|
|
177
|
+
#
|
|
178
|
+
# Intended for interactive use. For scoped work in production code,
|
|
179
|
+
# prefer {.with_session} — it auto-restores prior state on exit, even
|
|
180
|
+
# if the block raises.
|
|
181
|
+
#
|
|
182
|
+
# @param username [String] the user's username.
|
|
183
|
+
# @param password [String] the user's password.
|
|
184
|
+
# @param mfa_token [String, nil] one-time MFA code (TOTP or recovery
|
|
185
|
+
# code). When given, the credentials are submitted via the MFA
|
|
186
|
+
# endpoint. When the server requires MFA and none is supplied,
|
|
187
|
+
# {Parse::MFA::RequiredError} is raised so the caller can prompt
|
|
188
|
+
# for the code and retry.
|
|
189
|
+
# @return [Parse::User] the logged-in user.
|
|
190
|
+
# @raise [Parse::Error::AuthenticationError] when credentials are rejected.
|
|
191
|
+
# @raise [Parse::MFA::RequiredError] when the server requires an MFA
|
|
192
|
+
# token and `mfa_token:` was not provided.
|
|
193
|
+
# @raise [Parse::MFA::VerificationError] when the supplied `mfa_token:`
|
|
194
|
+
# is invalid or expired.
|
|
195
|
+
# @example IRB / rails console
|
|
196
|
+
# Parse.login("alice", "hunter2")
|
|
197
|
+
# Post.all # as alice
|
|
198
|
+
# p = Post.find(id); p.update!(title: "edited") # as alice
|
|
199
|
+
# Parse.logout # clears ambient and revokes the session
|
|
200
|
+
# @example with MFA
|
|
201
|
+
# begin
|
|
202
|
+
# Parse.login("alice", "hunter2")
|
|
203
|
+
# rescue Parse::MFA::RequiredError
|
|
204
|
+
# code = $stdin.gets.chomp
|
|
205
|
+
# Parse.login("alice", "hunter2", mfa_token: code)
|
|
206
|
+
# end
|
|
207
|
+
def self.login(username, password, mfa_token: nil)
|
|
208
|
+
user = if mfa_token
|
|
209
|
+
Parse::User.login_with_mfa(username, password, mfa_token)
|
|
210
|
+
else
|
|
211
|
+
Parse::User.login!(username, password)
|
|
212
|
+
end
|
|
213
|
+
unless user
|
|
214
|
+
raise Parse::Error::AuthenticationError,
|
|
215
|
+
"Parse.login: credentials rejected for #{username.inspect} (server returned no session)."
|
|
216
|
+
end
|
|
217
|
+
Fiber[SESSION_TOKEN_STATE_KEY] = user.session_token
|
|
218
|
+
Fiber[CURRENT_USER_STATE_KEY] = user
|
|
219
|
+
user
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Imperative logout: clears the ambient session token and cached
|
|
223
|
+
# current user for the current fiber and, by default, revokes the
|
|
224
|
+
# token server-side via `POST /parse/logout`. Pair with {.login}.
|
|
225
|
+
#
|
|
226
|
+
# If you set the ambient via {.session_token=} (no server-side
|
|
227
|
+
# session to revoke), pass `revoke: false` to skip the network call.
|
|
228
|
+
#
|
|
229
|
+
# @param revoke [Boolean] when true (default), call the server-side
|
|
230
|
+
# `/logout` endpoint to invalidate the token. When false, only
|
|
231
|
+
# clears local fiber state.
|
|
232
|
+
# @return [Boolean] true if the local state was cleared (always); the
|
|
233
|
+
# server-side revoke result is intentionally not surfaced — `logout`
|
|
234
|
+
# is fire-and-forget in console use.
|
|
235
|
+
def self.logout(revoke: true)
|
|
236
|
+
token = Fiber[SESSION_TOKEN_STATE_KEY]
|
|
237
|
+
Fiber[SESSION_TOKEN_STATE_KEY] = nil
|
|
238
|
+
Fiber[CURRENT_USER_STATE_KEY] = nil
|
|
239
|
+
if revoke && token.is_a?(String) && !token.empty?
|
|
240
|
+
begin
|
|
241
|
+
Parse::Client.client.logout(token)
|
|
242
|
+
rescue StandardError
|
|
243
|
+
# Best-effort: a failed revoke shouldn't make `logout` raise in
|
|
244
|
+
# a REPL. The local clear already happened.
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Imperative ambient-token setter, for cases where you already have a
|
|
251
|
+
# session token (e.g. read from a fixture, a test setup, a saved
|
|
252
|
+
# credential) and want to scope subsequent calls without going through
|
|
253
|
+
# the login endpoint. Set to `nil` to clear the ambient (does not
|
|
254
|
+
# revoke server-side; use {.logout} for that).
|
|
255
|
+
# @param token [String, Parse::User, Parse::Session, nil]
|
|
256
|
+
# @return [String, nil] the resolved token now in effect.
|
|
257
|
+
def self.session_token=(token)
|
|
258
|
+
resolved = token.respond_to?(:session_token) ? token.session_token : token
|
|
259
|
+
resolved = resolved.to_s if resolved
|
|
260
|
+
Fiber[SESSION_TOKEN_STATE_KEY] = (resolved && !resolved.empty?) ? resolved : nil
|
|
261
|
+
Fiber[CURRENT_USER_STATE_KEY] = nil
|
|
262
|
+
Fiber[SESSION_TOKEN_STATE_KEY]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Strict client mode — when true, the request layer never sends the
|
|
266
|
+
# configured master key unless the caller explicitly passes
|
|
267
|
+
# `use_master_key: true`. In combination with {Parse.with_session},
|
|
268
|
+
# this lets a same-process server+client deployment safely run a
|
|
269
|
+
# region of code "as a client" — every Parse call that isn't
|
|
270
|
+
# explicitly admin-flavored is scoped to the ambient session token (or
|
|
271
|
+
# sent anonymous if none is set), and the configured master key is
|
|
272
|
+
# ignored.
|
|
273
|
+
#
|
|
274
|
+
# **Honored ENV form:** `PARSE_CLIENT_MODE=true` at boot is equivalent
|
|
275
|
+
# to setting this to `true` before any Parse request goes out.
|
|
276
|
+
#
|
|
277
|
+
# @example Enable for the whole process
|
|
278
|
+
# Parse.client_mode = true
|
|
279
|
+
# Parse.with_session(user.session_token) do
|
|
280
|
+
# Post.all # as alice, no master key
|
|
281
|
+
# SecretAdminThing.find(id, use_master_key: true) # explicit override
|
|
282
|
+
# end
|
|
283
|
+
# @return [Boolean]
|
|
284
|
+
@client_mode = ENV["PARSE_CLIENT_MODE"] == "true"
|
|
285
|
+
def self.client_mode
|
|
286
|
+
@client_mode == true
|
|
287
|
+
end
|
|
288
|
+
def self.client_mode=(value)
|
|
289
|
+
@client_mode = (value == true)
|
|
290
|
+
end
|
|
291
|
+
def self.client_mode?
|
|
292
|
+
client_mode
|
|
293
|
+
end
|
|
294
|
+
|
|
83
295
|
# Configuration for query validation warnings
|
|
84
296
|
# Set to false to disable warnings about unnecessary includes
|
|
85
297
|
# @example Disable query warnings
|
|
@@ -119,13 +331,14 @@ module Parse
|
|
|
119
331
|
# # => [Parse::Fetch] Warning: unknown keys [:nonexistent] for Song
|
|
120
332
|
@validate_query_keys = true
|
|
121
333
|
|
|
122
|
-
#
|
|
123
|
-
# LiveQuery
|
|
124
|
-
#
|
|
125
|
-
#
|
|
334
|
+
# Opt-in toggle for the LiveQuery WebSocket subscription feature.
|
|
335
|
+
# LiveQuery has been stable since Parse Stack 3.0.0; the toggle exists
|
|
336
|
+
# so the network-egress surface (an outbound WebSocket to the LiveQuery
|
|
337
|
+
# server) is opened only when the operator explicitly turns it on, not
|
|
338
|
+
# as a side effect of requiring the file.
|
|
339
|
+
# @example Enable LiveQuery
|
|
126
340
|
# Parse.live_query_enabled = true
|
|
127
341
|
# require 'parse/live_query'
|
|
128
|
-
# @note WebSocket client implementation is incomplete
|
|
129
342
|
@live_query_enabled = false
|
|
130
343
|
|
|
131
344
|
# Configuration for cache write-through on fetch operations.
|
|
@@ -281,13 +494,112 @@ module Parse
|
|
|
281
494
|
# Parse.synchronize_classes = ["User", "Device", "Subscription"]
|
|
282
495
|
@synchronize_classes = nil
|
|
283
496
|
|
|
497
|
+
# Suppress the one-shot Parse Server version deprecation warning emitted
|
|
498
|
+
# by {Parse::API::Server#server_info} when the connected server is below
|
|
499
|
+
# the floor in {Parse::API::Server::DEPRECATED_SERVER_VERSION_BELOW}.
|
|
500
|
+
# Operators on a known-old Parse Server pinned for an explicit reason
|
|
501
|
+
# can set this once at boot; the ENV form
|
|
502
|
+
# `PARSE_SUPPRESS_SERVER_VERSION_WARNING=true` is honored equivalently.
|
|
503
|
+
# @example Silence in code
|
|
504
|
+
# Parse.suppress_server_version_warning = true
|
|
505
|
+
@suppress_server_version_warning = false
|
|
506
|
+
|
|
507
|
+
# Slow-query threshold for the bundled slow-query subscriber. When
|
|
508
|
+
# set to a positive integer, the SDK subscribes once to
|
|
509
|
+
# `parse.mongodb.aggregate` and `parse.mongodb.find` AS::N events
|
|
510
|
+
# and emits a `[Parse::MongoDB] SLOW` warning to `Parse.logger`
|
|
511
|
+
# whenever an event's wall-clock duration exceeds the threshold (in
|
|
512
|
+
# milliseconds). The log line contains ONLY metadata — collection,
|
|
513
|
+
# scope, stage_count/stage_types (aggregate), or has_filter/
|
|
514
|
+
# projection_keys (find), result_count, max_time_ms. Pipeline
|
|
515
|
+
# bodies, filter bodies, and result rows are never included.
|
|
516
|
+
#
|
|
517
|
+
# The threshold is re-read on every event, so toggling
|
|
518
|
+
# `Parse.slow_query_threshold_ms = nil` at runtime silences the
|
|
519
|
+
# logger without unsubscribing. The ENV form
|
|
520
|
+
# `PARSE_SLOW_QUERY_THRESHOLD_MS=250` is honored equivalently and
|
|
521
|
+
# is bootstrapped at module-load (setting the ENV before `require
|
|
522
|
+
# "parse/stack"` is sufficient — no explicit setter call needed).
|
|
523
|
+
# Operators who already subscribe to the raw AS::N events from
|
|
524
|
+
# their APM/OTel layer don't need this knob.
|
|
525
|
+
# @example
|
|
526
|
+
# Parse.slow_query_threshold_ms = 250
|
|
527
|
+
@slow_query_threshold_ms = nil
|
|
528
|
+
@slow_query_subscribed = false
|
|
529
|
+
|
|
284
530
|
class << self
|
|
285
531
|
attr_accessor :warn_on_query_issues, :autofetch_raise_on_missing_keys, :serialize_only_fetched_fields, :validate_query_keys,
|
|
286
532
|
:live_query_enabled, :cache_write_on_fetch, :default_query_cache, :mcp_server_enabled, :mcp_server_port, :mcp_remote_api,
|
|
287
533
|
:rewrite_lookups, :strict_property_redefinition,
|
|
288
534
|
:synchronize_create_default, :synchronize_create_options, :synchronize_create_secret,
|
|
289
535
|
:synchronize_create_store, :synchronize_classes,
|
|
290
|
-
:strict_pointer_shapes
|
|
536
|
+
:strict_pointer_shapes, :suppress_server_version_warning
|
|
537
|
+
|
|
538
|
+
# Check whether the Parse Server version deprecation warning is
|
|
539
|
+
# silenced. Returns true if either the in-process accessor or the
|
|
540
|
+
# `PARSE_SUPPRESS_SERVER_VERSION_WARNING` ENV is set.
|
|
541
|
+
# @return [Boolean]
|
|
542
|
+
def suppress_server_version_warning?
|
|
543
|
+
@suppress_server_version_warning == true || ENV["PARSE_SUPPRESS_SERVER_VERSION_WARNING"] == "true"
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Current slow-query threshold in milliseconds, or `nil` when
|
|
547
|
+
# unconfigured. Resolves the in-process accessor first; falls back
|
|
548
|
+
# to the `PARSE_SLOW_QUERY_THRESHOLD_MS` ENV. Non-positive values
|
|
549
|
+
# are treated as `nil` (disabled).
|
|
550
|
+
# @return [Integer, nil]
|
|
551
|
+
def slow_query_threshold_ms
|
|
552
|
+
value = @slow_query_threshold_ms
|
|
553
|
+
value = ENV["PARSE_SLOW_QUERY_THRESHOLD_MS"].to_i if value.nil? && ENV["PARSE_SLOW_QUERY_THRESHOLD_MS"]
|
|
554
|
+
value && value > 0 ? value : nil
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Set the slow-query threshold in milliseconds. When set to a
|
|
558
|
+
# positive integer, lazily attaches the bundled subscriber to
|
|
559
|
+
# `parse.mongodb.aggregate` and `parse.mongodb.find` so events
|
|
560
|
+
# exceeding the threshold log a warning to {Parse.logger}. Set
|
|
561
|
+
# to `nil` (or any non-positive value) to disable; the subscriber
|
|
562
|
+
# stays attached but becomes a cheap pass-through.
|
|
563
|
+
# @param value [Integer, nil]
|
|
564
|
+
def slow_query_threshold_ms=(value)
|
|
565
|
+
@slow_query_threshold_ms = value
|
|
566
|
+
_attach_slow_query_subscriber!
|
|
567
|
+
value
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# @!visibility private
|
|
571
|
+
# Attach the slow-query subscriber exactly once per process. The
|
|
572
|
+
# subscriber re-reads {Parse.slow_query_threshold_ms} on every
|
|
573
|
+
# event so toggling the knob at runtime takes effect without a
|
|
574
|
+
# resubscribe. Safe to call repeatedly — guarded by
|
|
575
|
+
# `@slow_query_subscribed`.
|
|
576
|
+
def _attach_slow_query_subscriber!
|
|
577
|
+
return if @slow_query_subscribed
|
|
578
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
579
|
+
@slow_query_subscribed = true
|
|
580
|
+
handler = lambda do |name, started, finished, _id, payload|
|
|
581
|
+
threshold = slow_query_threshold_ms
|
|
582
|
+
next if threshold.nil?
|
|
583
|
+
duration_ms = ((finished - started) * 1000.0).round(1)
|
|
584
|
+
next if duration_ms < threshold
|
|
585
|
+
logger = respond_to?(:logger) ? Parse.logger : nil
|
|
586
|
+
next unless logger
|
|
587
|
+
detail =
|
|
588
|
+
if name == "parse.mongodb.aggregate"
|
|
589
|
+
"stages=#{payload[:stage_count]} types=#{Array(payload[:stage_types]).join(',')}"
|
|
590
|
+
else
|
|
591
|
+
"filter=#{!!payload[:has_filter]} projection=#{Array(payload[:projection_keys]).join(',')}"
|
|
592
|
+
end
|
|
593
|
+
logger.warn(
|
|
594
|
+
"[Parse::MongoDB] SLOW #{name} #{duration_ms}ms " \
|
|
595
|
+
"collection=#{payload[:collection]} scope=#{payload[:scope] || 'n/a'} " \
|
|
596
|
+
"#{detail} result_count=#{payload[:result_count] || 'n/a'} " \
|
|
597
|
+
"max_time_ms=#{payload[:max_time_ms] || 'n/a'}",
|
|
598
|
+
)
|
|
599
|
+
end
|
|
600
|
+
ActiveSupport::Notifications.subscribe("parse.mongodb.aggregate", &handler)
|
|
601
|
+
ActiveSupport::Notifications.subscribe("parse.mongodb.find", &handler)
|
|
602
|
+
end
|
|
291
603
|
|
|
292
604
|
# Check if LiveQuery feature is enabled
|
|
293
605
|
# @return [Boolean]
|
|
@@ -333,6 +645,54 @@ module Parse
|
|
|
333
645
|
def mcp_remote_api_configured?
|
|
334
646
|
@mcp_remote_api.is_a?(Hash) && @mcp_remote_api[:api_key].present?
|
|
335
647
|
end
|
|
648
|
+
|
|
649
|
+
# Send an analytics event to Parse Server's REST `/events/<name>`
|
|
650
|
+
# endpoint. Thin shortcut around {Parse::Client#send_analytics} so
|
|
651
|
+
# callers don't have to reach into `Parse.client` directly.
|
|
652
|
+
#
|
|
653
|
+
# Dimensions MUST be passed via the `dimensions:` keyword. Loose
|
|
654
|
+
# symbol-keyed arguments at the call site would otherwise be
|
|
655
|
+
# absorbed by `**opts` under Ruby 3's strict keyword separation,
|
|
656
|
+
# and the dimensions would never reach Parse Server — the POST
|
|
657
|
+
# would land with an empty body. Forwarded `**opts` is reserved
|
|
658
|
+
# for request-layer kwargs (`session_token:`, `use_master_key:`,
|
|
659
|
+
# etc.).
|
|
660
|
+
#
|
|
661
|
+
# Parse Server's default analytics adapter is a no-op — events
|
|
662
|
+
# POSTed to `/events` are accepted but neither persisted nor
|
|
663
|
+
# queryable through the SDK. Operators who configure a custom
|
|
664
|
+
# `analyticsAdapter` decide what (if anything) to do with the
|
|
665
|
+
# event and whether to cap dimension count. The legacy parse.com
|
|
666
|
+
# eight-dimension cap does NOT apply to Parse Server out of the
|
|
667
|
+
# box. If you need to read events back, persist them to a regular
|
|
668
|
+
# `Parse::Object` subclass.
|
|
669
|
+
#
|
|
670
|
+
# The underlying request is a blocking HTTP POST — wrap in a
|
|
671
|
+
# thread or background job if you don't want it on the request
|
|
672
|
+
# path.
|
|
673
|
+
#
|
|
674
|
+
# @param name [String, Symbol] event name (e.g. "post_viewed",
|
|
675
|
+
# "AppOpened"). Restricted to word characters, hyphens, and
|
|
676
|
+
# dots so the value cannot escape the `/events/` path segment.
|
|
677
|
+
# @param dimensions [Hash] dimension pairs. Values must be
|
|
678
|
+
# JSON-serializable.
|
|
679
|
+
# @param opts [Hash] forwarded to {Parse::Client#request}.
|
|
680
|
+
# @return [Parse::Response] the response.
|
|
681
|
+
# @raise [ArgumentError] when `name` is empty or contains
|
|
682
|
+
# characters outside `[\w\-\.]`.
|
|
683
|
+
# @example
|
|
684
|
+
# Parse.track_event("post_viewed", dimensions: { source: "feed", workspace: "w1" })
|
|
685
|
+
# Parse.track_event("AppOpened")
|
|
686
|
+
# Parse.track_event("error", dimensions: { code: "E_RATE_LIMIT" })
|
|
687
|
+
def track_event(name, dimensions: {}, **opts)
|
|
688
|
+
event_name = name.to_s
|
|
689
|
+
unless event_name.match?(/\A[\w\-\.]+\z/)
|
|
690
|
+
raise ArgumentError,
|
|
691
|
+
"Parse.track_event: event name must contain only word characters, " \
|
|
692
|
+
"hyphens, or dots (got #{name.inspect})"
|
|
693
|
+
end
|
|
694
|
+
Parse.client.send_analytics(event_name, dimensions, **opts)
|
|
695
|
+
end
|
|
336
696
|
end
|
|
337
697
|
|
|
338
698
|
# Error raised when {Parse::CreateLock#synchronize} cannot acquire the
|
|
@@ -369,70 +729,6 @@ module Parse
|
|
|
369
729
|
end
|
|
370
730
|
end
|
|
371
731
|
end
|
|
372
|
-
|
|
373
|
-
# Special class to support Modernistik Hyperdrive server.
|
|
374
|
-
class Hyperdrive
|
|
375
|
-
# Applies a remote JSON hash containing the ENV keys and values from a remote
|
|
376
|
-
# URL. Values from the JSON hash are only applied to the current ENV hash ONLY if
|
|
377
|
-
# it does not already have a value. Therefore local ENV values will take precedence
|
|
378
|
-
# over remote ones. By default, it uses the url in environment value in 'CONFIG_URL' or 'HYPERDRIVE_URL'.
|
|
379
|
-
# @param url [String] the remote url that responds with the JSON body.
|
|
380
|
-
# @return [Boolean] true if the JSON hash was found and applied successfully.
|
|
381
|
-
def self.config!(url = nil)
|
|
382
|
-
url ||= ENV["HYPERDRIVE_URL"] || ENV["CONFIG_URL"]
|
|
383
|
-
return false if url.blank?
|
|
384
|
-
|
|
385
|
-
begin
|
|
386
|
-
uri = URI.parse(url)
|
|
387
|
-
|
|
388
|
-
# Security: Only allow HTTPS or localhost HTTP for development
|
|
389
|
-
unless uri.is_a?(URI::HTTPS) || (uri.is_a?(URI::HTTP) && %w[localhost 127.0.0.1].include?(uri.host))
|
|
390
|
-
warn "[Parse::Stack] Security: Config URL must be HTTPS (got: #{url})"
|
|
391
|
-
return false
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
# Use Net::HTTP instead of open-uri to avoid command injection via pipe characters
|
|
395
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
396
|
-
http.use_ssl = uri.scheme == "https"
|
|
397
|
-
http.open_timeout = 10
|
|
398
|
-
http.read_timeout = 10
|
|
399
|
-
|
|
400
|
-
request = Net::HTTP::Get.new(uri.request_uri)
|
|
401
|
-
response = http.request(request)
|
|
402
|
-
|
|
403
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
404
|
-
warn "[Parse::Stack] Config fetch failed: #{url} (HTTP #{response.code})"
|
|
405
|
-
return false
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
# Parse JSON safely
|
|
409
|
-
remote_config = JSON.parse(response.body)
|
|
410
|
-
|
|
411
|
-
unless remote_config.is_a?(Hash)
|
|
412
|
-
warn "[Parse::Stack] Config must be a JSON object: #{url}"
|
|
413
|
-
return false
|
|
414
|
-
end
|
|
415
|
-
|
|
416
|
-
remote_config.each do |key, value|
|
|
417
|
-
k = key.to_s.upcase
|
|
418
|
-
# Validate key format to prevent injection
|
|
419
|
-
next unless k.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
420
|
-
next unless ENV[k].nil?
|
|
421
|
-
ENV[k] = value.to_s
|
|
422
|
-
end
|
|
423
|
-
true
|
|
424
|
-
rescue URI::InvalidURIError => e
|
|
425
|
-
warn "[Parse::Stack] Invalid config URL: #{url} (#{e.message})"
|
|
426
|
-
false
|
|
427
|
-
rescue JSON::ParserError => e
|
|
428
|
-
warn "[Parse::Stack] Invalid JSON in config: #{url} (#{e.message})"
|
|
429
|
-
false
|
|
430
|
-
rescue StandardError => e
|
|
431
|
-
warn "[Parse::Stack] Error loading config: #{url} (#{e.class}: #{e.message})"
|
|
432
|
-
false
|
|
433
|
-
end
|
|
434
|
-
end
|
|
435
|
-
end
|
|
436
732
|
end
|
|
437
733
|
|
|
438
734
|
# Startup warning: If ENV is set but programmatic flag isn't, warn the user
|
|
@@ -452,4 +748,10 @@ if Parse.synchronize_create_default && Parse.synchronize_classes.nil?
|
|
|
452
748
|
"to restrict the surface, or audit each call site."
|
|
453
749
|
end
|
|
454
750
|
|
|
751
|
+
# Auto-attach the slow-query subscriber when the threshold is supplied
|
|
752
|
+
# at boot via ENV. The programmatic setter handles the in-process case;
|
|
753
|
+
# the ENV path needs an explicit kick because nothing else calls into
|
|
754
|
+
# the setter on load.
|
|
755
|
+
Parse._attach_slow_query_subscriber! if Parse.slow_query_threshold_ms
|
|
756
|
+
|
|
455
757
|
require_relative "stack/railtie" if defined?(::Rails)
|
|
@@ -53,7 +53,9 @@ module Parse
|
|
|
53
53
|
response = client.login_with_mfa(username, password, mfa_token)
|
|
54
54
|
return nil unless response.success?
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
# Self-fetch trust: an MFA login returns the authenticating
|
|
57
|
+
# user's own row, so authData here is legitimately theirs.
|
|
58
|
+
Parse::User.with_authdata_trust { Parse::User.build(response.result) }
|
|
57
59
|
rescue Parse::Client::ResponseError => e
|
|
58
60
|
if e.message.include?("Invalid MFA token") || e.message.include?("Missing additional authData")
|
|
59
61
|
raise MFA::VerificationError, e.message
|
|
@@ -117,7 +119,8 @@ module Parse
|
|
|
117
119
|
#
|
|
118
120
|
# @param secret [String] Base32-encoded TOTP secret (generate with MFA.generate_secret)
|
|
119
121
|
# @param token [String] Current TOTP code for verification (user enters from app)
|
|
120
|
-
# @return [String] Recovery codes (comma-separated) - SAVE THESE!
|
|
122
|
+
# @return [String, nil] Recovery codes (comma-separated) - SAVE THESE!
|
|
123
|
+
# May be nil if the Parse Server response does not include them.
|
|
121
124
|
# @raise [Parse::MFA::VerificationError] If token is invalid
|
|
122
125
|
# @raise [Parse::MFA::AlreadyEnabledError] If MFA is already enabled
|
|
123
126
|
# @raise [ArgumentError] If secret or token is blank
|