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
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
- # Configuration for experimental LiveQuery feature.
123
- # LiveQuery provides real-time WebSocket subscriptions for reactive applications.
124
- # This feature is experimental and not fully implemented. Enable at your own risk.
125
- # @example Enable LiveQuery (experimental)
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
- Parse::User.build(response.result)
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