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
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "faraday"
5
5
  require "moneta"
6
+ require "connection_pool"
6
7
  require "digest"
7
8
  require_relative "protocol"
8
9
 
@@ -81,6 +82,14 @@ module Parse
81
82
  @opts = { expires: 0 }
82
83
  @opts.merge!(opts) if opts.is_a?(Hash)
83
84
  @expires = @opts[:expires]
85
+ # Optional cache key namespace so two Parse apps sharing one Redis don't
86
+ # collide (e.g. `mk:/classes/Song/abc` is the same path for both apps).
87
+ # When set, keys become `<namespace>:<existing-prefix>:<url>`. Empty
88
+ # string is treated as nil. Trailing `:` is stripped once so users can
89
+ # pass either `"app_x"` or `"app_x:"`.
90
+ ns = @opts[:namespace].to_s
91
+ ns = ns.chomp(":")
92
+ @namespace = ns.empty? ? nil : ns
84
93
 
85
94
  unless [:key?, :[], :delete, :store].all? { |method| @store.respond_to?(method) }
86
95
  raise ArgumentError, "Caching store object must a Moneta key/value store."
@@ -134,21 +143,36 @@ module Parse
134
143
  @cache_key = "mk:#{@cache_key}" # prefix for master key requests
135
144
  end
136
145
 
146
+ # Namespace outermost so a SCAN over `<namespace>:*` evicts a whole
147
+ # tenant/app cleanly without touching another app's entries.
148
+ @cache_key = "#{@namespace}:#{@cache_key}" if @namespace
149
+
150
+ url_path = url.path
151
+
137
152
  begin
138
153
  # Skip cache read if write_only mode is enabled
139
154
  if method == :get && @cache_key.present? && !@write_only && @store.key?(@cache_key)
140
- puts("[Parse::Cache] Hit >> #{url}") if self.class.logging.present?
155
+ # Debug-log the URL **path only** — `url.to_s` would include the
156
+ # query string, which Parse encodes JSON `where=` into and may
157
+ # contain PII. Same redaction discipline as the AS::N payload.
158
+ puts("[Parse::Cache] Hit >> #{url_path}") if self.class.logging.present?
141
159
  response = Faraday::Response.new
142
160
  begin
143
161
  cache_data = @store[@cache_key] # previous cached response
144
162
  rescue => e
145
- puts "[Parse::Cache] Error: #{e}"
163
+ # Log only the class name — some Moneta/Redis drivers echo the
164
+ # offending key in `e.message`, and our key contains a hashed
165
+ # session-token prefix that we treat as side-channel material.
166
+ puts "[Parse::Cache] Error: #{e.class.name}"
167
+ instrument_cache(:error, method: method, url_path: url_path, error: e.class.name)
146
168
  cache_data = nil
147
169
  end
148
170
 
149
171
  # check if the store was from a legacy parse-stack cache value which
150
172
  # is stored as Faraday::Env. T\he new system stores less content in a simple hash
151
173
  # for improved interoperability and access time.
174
+ body = nil
175
+ response_headers = nil
152
176
  if cache_data.is_a?(Faraday::Env)
153
177
  body = cache_data.respond_to?(:body) ? cache_data.body : nil
154
178
  response_headers = cache_data.response_headers || {}
@@ -160,24 +184,43 @@ module Parse
160
184
  if cache_data.present? && body.present?
161
185
  response_headers[CACHE_RESPONSE_HEADER] = "true"
162
186
  response.finish({ status: 200, response_headers: response_headers, body: body })
187
+ instrument_cache(:hit, method: method, url_path: url_path)
163
188
  return response
164
189
  else
165
- @store.delete @cache_key
190
+ delete_cache_variants(url)
191
+ instrument_cache(:miss, method: method, url_path: url_path, reason: :empty_payload)
166
192
  end
193
+ elsif method == :get && @cache_key.present? && !@write_only
194
+ # GET miss: opportunistically clear any sibling variants of the
195
+ # current namespace (anonymous `<url>` and master-key `mk:<url>`
196
+ # under the same namespace) so a stale variant from a prior
197
+ # request flavor doesn't linger until TTL.
198
+ #
199
+ # When @namespace is set we deliberately do NOT touch the bare
200
+ # un-namespaced `<url>` / `mk:<url>` keys — those could belong to
201
+ # another Parse app sharing the Redis DB, and cross-namespace
202
+ # eviction would be a blast-radius bug, not a fix. Operators
203
+ # upgrading an SDK that previously wrote un-namespaced keys
204
+ # should evict those once at upgrade time via SCAN.
205
+ delete_cache_variants(url)
206
+ instrument_cache(:miss, method: method, url_path: url_path)
207
+ elsif method == :get && @cache_key.present? && @write_only
208
+ delete_cache_variants(url)
209
+ instrument_cache(:miss, method: method, url_path: url_path, reason: :write_only)
167
210
  elsif @cache_key.present?
168
211
  #non GET requets should clear the cache for that same resource path.
169
212
  #ex. a POST to /1/classes/Artist/<objectId> should delete the cache for a GET
170
213
  # request for the same '/1/classes/Artist/<objectId>' where objectId are equivalent
171
- @store.delete url.to_s # regular
172
- @store.delete "mk:#{url.to_s}" # master key cache-key
173
- @store.delete @cache_key # final key
214
+ delete_cache_variants(url)
215
+ instrument_cache(:delete, method: method, url_path: url_path)
174
216
  end
175
- rescue ::TypeError, Errno::EINVAL, Redis::CannotConnectError, Redis::TimeoutError => e
217
+ rescue ::TypeError, Errno::EINVAL, Redis::CannotConnectError, Redis::TimeoutError, ConnectionPool::TimeoutError => e
176
218
  # if the cache store fails to connect, catch the exception but proceed
177
219
  # with the regular request, but turn off caching for this request. It is possible
178
220
  # that the cache connection resumes at a later point, so this is temporary.
179
221
  @enabled = false
180
- puts "[Parse::Cache] Error: #{e}"
222
+ puts "[Parse::Cache] Error: #{e.class.name}"
223
+ instrument_cache(:error, method: method, url_path: url_path, error: e.class.name)
181
224
  end
182
225
 
183
226
  @app.call(env).on_complete do |response_env|
@@ -186,18 +229,75 @@ module Parse
186
229
 
187
230
  if @enabled && method == :get && CACHEABLE_HTTP_CODES.include?(response_env.status) &&
188
231
  response_env.body.present? && response_env.response_headers[CONTENT_LENGTH_KEY].to_i.between?(20, 1_250_000)
232
+ store_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
233
  begin
190
234
  @store.store(@cache_key,
191
235
  { headers: response_env.response_headers, body: response_env.body },
192
236
  expires: @expires)
237
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - store_start) * 1000.0).round(3)
238
+ instrument_cache(:store, method: method, url_path: url_path, duration_ms: duration_ms)
193
239
  rescue => e
194
- puts "[Parse::Cache] Store Error: #{e}"
240
+ puts "[Parse::Cache] Store Error: #{e.class.name}"
241
+ instrument_cache(:error, method: method, url_path: url_path, error: e.class.name)
195
242
  end
196
243
  end # if
197
244
  # do something with the response
198
245
  # response_env[:response_headers].merge!(...)
199
246
  end
200
247
  end
248
+
249
+ private
250
+
251
+ # Emit an ActiveSupport::Notifications event under the `parse.cache.*`
252
+ # namespace.
253
+ #
254
+ # **Payload shape (stable):** `{ event:, namespace:, method:, url_path:,
255
+ # [reason:], [duration_ms:], [error:] }`.
256
+ #
257
+ # **Security invariants:**
258
+ # - The cache key is NEVER emitted. The key contains a hashed
259
+ # session-token prefix that would be a side-channel for "this user
260
+ # has data at this URL" enumeration.
261
+ # - `url_path` is `URI#path` only — query strings are stripped because
262
+ # Parse encodes query JSON there (potentially long or PII-bearing).
263
+ # - `error` is `Exception#class.name` only — never the exception
264
+ # message or backtrace.
265
+ # - `namespace` is whatever the SDK consumer configured at setup. Treat
266
+ # subscribers as you would your application log sink: they observe
267
+ # the namespace, the HTTP method, and the URL path of every cached
268
+ # GET / invalidating write.
269
+ #
270
+ # **Subscriber discipline:** ActiveSupport::Notifications runs
271
+ # subscribers **synchronously on the Faraday request thread**. A
272
+ # blocking subscriber (e.g. synchronous I/O to a slow sink) blocks
273
+ # every cached request for the duration of its work, and an exception
274
+ # raised inside a subscriber will surface as a request failure. Keep
275
+ # subscribers cheap — counter increments, in-memory accumulators, or
276
+ # non-blocking sinks like StatsD-over-UDP.
277
+ # @!visibility private
278
+ def instrument_cache(event, **extra)
279
+ return unless defined?(ActiveSupport::Notifications)
280
+ payload = { event: event, namespace: @namespace }.merge!(extra)
281
+ ActiveSupport::Notifications.instrument("parse.cache.#{event}", payload)
282
+ end
283
+
284
+ # Delete the canonical cache_key plus its legacy un-namespaced and
285
+ # master-key-prefixed variants. Called on both GET misses (defensive
286
+ # cleanup of stale pre-namespace entries) and non-GET writes (cache
287
+ # invalidation for the resource).
288
+ # @!visibility private
289
+ def delete_cache_variants(url)
290
+ if @namespace
291
+ # Namespaced: only delete our app's variants so a write through
292
+ # client A doesn't blow away client B's cache when both share Redis.
293
+ @store.delete "#{@namespace}:#{url.to_s}"
294
+ @store.delete "#{@namespace}:mk:#{url.to_s}"
295
+ else
296
+ @store.delete url.to_s # regular
297
+ @store.delete "mk:#{url.to_s}" # master key cache-key
298
+ end
299
+ @store.delete @cache_key # final key
300
+ end
201
301
  end #Caching
202
302
  end #Middleware
203
303
  end
@@ -33,6 +33,14 @@ module Parse
33
33
  ERROR_EMAIL_NOT_FOUND = 205
34
34
  # Code when the email is invalid
35
35
  ERROR_EMAIL_INVALID = 125
36
+ # Code returned for invalid or expired session tokens (Parse Server).
37
+ ERROR_INVALID_SESSION_TOKEN = 209
38
+ # Code returned for operations that are not permitted under the
39
+ # caller's ACL / CLP / authentication scope. Parse Server uses
40
+ # this code for both "you are not authenticated for this" and
41
+ # "you are authenticated, but not authorized" — see
42
+ # {#permission_denied?} for the SDK-friendly predicate.
43
+ ERROR_OPERATION_FORBIDDEN = 119
36
44
 
37
45
  # The field name for the error.
38
46
  ERROR = "error".freeze
@@ -171,6 +179,25 @@ module Parse
171
179
  @code == ERROR_OBJECT_NOT_FOUND
172
180
  end
173
181
 
182
+ # true if the response indicates the caller is not authorized to
183
+ # perform the requested operation. Parse Server signals authorization
184
+ # failure in two shapes that no-master-key clients commonly hit:
185
+ #
186
+ # - HTTP 403 with no body (sometimes 401) — recorded as
187
+ # `http_status` only, with no error code in the JSON.
188
+ # - HTTP 400 + error code 119 (`OPERATION_FORBIDDEN`) — typical for
189
+ # CLP and `protectedFields` denials.
190
+ # - HTTP 400 + error code 209 (`INVALID_SESSION_TOKEN`) — session
191
+ # token missing, revoked, or expired.
192
+ #
193
+ # This predicate normalizes those into a single check so client code
194
+ # doesn't have to remember both the HTTP-status and code-only paths.
195
+ # @return [Boolean]
196
+ def permission_denied?
197
+ return true if @http_status == 401 || @http_status == 403
198
+ @code == ERROR_OPERATION_FORBIDDEN || @code == ERROR_INVALID_SESSION_TOKEN
199
+ end
200
+
174
201
  # @return [Array] the result data from the response.
175
202
  def results
176
203
  return [] if @result.nil?
data/lib/parse/client.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "faraday"
2
4
 
3
5
  # Attempt to load the persistent connection adapter for better performance.
@@ -29,6 +31,7 @@ require_relative "client/batch"
29
31
  require_relative "client/body_builder"
30
32
  require_relative "client/authentication"
31
33
  require_relative "client/caching"
34
+ require_relative "cache/redis"
32
35
  require_relative "client/logging"
33
36
  require_relative "client/profiling"
34
37
  require_relative "api/all"
@@ -392,10 +395,13 @@ module Parse
392
395
  # Parse.setup(
393
396
  # connection_pooling: { pool_size: 5, idle_timeout: 60, keep_alive: 60 }
394
397
  # )
395
- # @option opts [Moneta::Transformer,Moneta::Expires] :cache A caching adapter of type
398
+ # @option opts [Moneta::Transformer,Moneta::Expires,Parse::Cache::Redis,String] :cache A caching adapter of type
396
399
  # {https://github.com/minad/moneta Moneta::Transformer} or
397
400
  # {https://github.com/minad/moneta Moneta::Expires} that will be used
398
- # by the caching middleware {Parse::Middleware::Caching}.
401
+ # by the caching middleware {Parse::Middleware::Caching}. You can also
402
+ # pass a `redis://` URL string (an internal Moneta-Redis store will be
403
+ # built for you) or a {Parse::Cache::Redis} wrapper, which adds a
404
+ # connection pool and automatic namespace forwarding.
399
405
  # Caching queries and object fetches can help improve the performance of
400
406
  # your application, even if it is for a few seconds. Only successful GET
401
407
  # object fetches and non-empty result queries will be cached by default.
@@ -407,6 +413,13 @@ module Parse
407
413
  # middleware. The default value is 3 seconds. If :expires is set to 0,
408
414
  # caching will be disabled. You can always clear the current state of the
409
415
  # cache using the clear_cache! method on your Parse::Client instance.
416
+ # @option opts [String] :cache_namespace Optional prefix applied to every
417
+ # cache key. Useful when two Parse apps share one Redis instance and
418
+ # would otherwise collide on identical paths (e.g.
419
+ # `mk:/classes/Song/abc`). Keys become `<namespace>:<existing-prefix>:<url>`,
420
+ # so a `SCAN <namespace>:*` evicts a whole app cleanly. Defaults to no
421
+ # namespace for backward compatibility — explicit only, never auto-derived
422
+ # from `app_id`.
410
423
  # @option opts [Hash] :faraday You may pass a hash of options that will be
411
424
  # passed to the Faraday constructor.
412
425
  # @option opts [String] :live_query_url The WebSocket URL for Parse LiveQuery server
@@ -565,8 +578,23 @@ module Parse
565
578
  unless [:key?, :[], :delete, :store].all? { |method| opts[:cache].respond_to?(method) }
566
579
  raise ArgumentError, "Parse::Client option :cache needs to be a type of Moneta store"
567
580
  end
581
+
582
+ # If the caller passed a `Parse::Cache::Redis` wrapper, let its
583
+ # built-in namespace flow through automatically. An explicit
584
+ # `cache_namespace:` still wins so callers can override.
585
+ if defined?(Parse::Cache::Redis) && opts[:cache].is_a?(Parse::Cache::Redis)
586
+ opts[:cache_namespace] ||= opts[:cache].namespace
587
+ end
588
+
568
589
  self.cache = opts[:cache]
569
- conn.use Parse::Middleware::Caching, self.cache, { expires: opts[:expires].to_i }
590
+ conn.use Parse::Middleware::Caching, self.cache, {
591
+ expires: opts[:expires].to_i,
592
+ # Optional `cache_namespace:` prefixes every key so two Parse
593
+ # apps sharing one Redis don't collide on `mk:/classes/Song/abc`.
594
+ # Explicit only — we do NOT auto-derive from app_id to keep
595
+ # existing single-app deployments backward-compatible.
596
+ namespace: opts[:cache_namespace],
597
+ }
570
598
 
571
599
  # Inform about opt-in cache behavior
572
600
  unless Parse.default_query_cache
@@ -738,6 +766,29 @@ module Parse
738
766
  # @see Parse::Protocol
739
767
  # @see Parse::Request
740
768
  def request(method, uri = nil, body: nil, query: nil, headers: nil, opts: {})
769
+ # Pre-declare locals referenced inside rescue blocks so CodeQL's
770
+ # uninitialized-variable analysis is satisfied even if an exception
771
+ # raises before the natural assignment site.
772
+ response = nil
773
+ _retry_count = nil
774
+ _retry_delay = nil
775
+ _request = nil
776
+ # Kwarg-absorption guard. The `**opts` splat in API helper methods
777
+ # (lib/parse/api/*.rb) absorbs a caller-passed `opts: { ... }`
778
+ # keyword as a key named `:opts` rather than as the request options
779
+ # hash itself. The auth context (session_token, use_master_key)
780
+ # buried under :opts then never reaches the request — the call
781
+ # silently goes out anonymous (or master, if one is configured),
782
+ # which is a permission-downgrade footgun. Fail loudly here so the
783
+ # bug surfaces in dev/test instead of in production.
784
+ if opts.is_a?(Hash) && opts[:opts].is_a?(Hash)
785
+ raise ArgumentError, "Parse::Client#request received nested `opts: { opts: { ... } }` — " \
786
+ "pass session_token: / use_master_key: directly as keywords, " \
787
+ "not wrapped in an `opts:` hash. " \
788
+ "Bad: Parse.client.create_object('X', body, opts: { session_token: t }) " \
789
+ "Good: Parse.client.create_object('X', body, session_token: t, use_master_key: false)"
790
+ end
791
+
741
792
  _retry_count ||= self.retry_limit
742
793
 
743
794
  if opts[:retry] == false
@@ -776,11 +827,31 @@ module Parse
776
827
  headers[Parse::Middleware::Caching::CACHE_EXPIRES_DURATION] = opts[:cache].to_s
777
828
  end
778
829
 
830
+ # Resolve the auth context in three layers:
831
+ # 1. explicit per-call `use_master_key:` and `session_token:`
832
+ # 2. ambient session set by `Parse.with_session { ... }` (fiber-local)
833
+ # 3. process-wide `Parse.client_mode` flag — when true, master key is
834
+ # never sent unless the caller explicitly passed `use_master_key: true`
835
+ explicit_master = opts.key?(:use_master_key)
836
+
779
837
  if opts[:use_master_key] == false
780
838
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
839
+ elsif Parse.client_mode && opts[:use_master_key] != true
840
+ # client mode defaults master key OFF unless explicitly opted in
841
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
781
842
  end
782
843
 
783
844
  token = opts[:session_token]
845
+ # When no explicit token was passed AND the caller didn't ask to send
846
+ # the master key, fall through to the fiber-local ambient set by
847
+ # `Parse.with_session`. Explicit `use_master_key: true` is treated as
848
+ # a deliberate admin call and skips the ambient — otherwise an
849
+ # `admin.do_thing(use_master_key: true)` nested inside a
850
+ # `with_session(user)` block would silently downgrade.
851
+ if token.nil? && !(explicit_master && opts[:use_master_key] == true)
852
+ ambient = Parse.current_session_token
853
+ token = ambient if ambient.is_a?(String) && !ambient.empty?
854
+ end
784
855
  if token.present?
785
856
  token = token.session_token if token.respond_to?(:session_token)
786
857
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
@@ -0,0 +1,203 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Console-friendly helpers for interactive Parse sessions.
5
+ #
6
+ # `watch` and `wait_for` wrap the LiveQuery subscription machinery in a
7
+ # blocking-by-default shape suited for `bin/rails console`, `bin/console`,
8
+ # or one-off Rake tasks where the caller wants to:
9
+ #
10
+ # - Tail a class as rows arrive ("watch new posts"), Ctrl-C to stop.
11
+ # - Block until a specific row appears or a condition matches
12
+ # ("wait until job N flips to :done"), with an optional timeout.
13
+ #
14
+ # Auth resolution is automatic:
15
+ # - If `Parse.current_session_token` is set (via `Parse.login`,
16
+ # `Parse.with_session`, or `Parse.session_token=`), the subscription
17
+ # is ACL-aware as that user.
18
+ # - Otherwise it falls through with no token — the LiveQuery server
19
+ # then applies whatever default the master-key / unauthenticated
20
+ # subscription model dictates for the class.
21
+ #
22
+ # Both helpers also accept an explicit `session_token:` kwarg for
23
+ # tests / fixtures.
24
+
25
+ require "timeout"
26
+
27
+ module Parse
28
+ module Console
29
+ DEFAULT_WATCH_EVENTS = [:create, :update, :delete, :enter, :leave].freeze
30
+ DEFAULT_WAIT_FOR_EVENTS = [:create, :enter].freeze
31
+
32
+ module_function
33
+
34
+ # Open a LiveQuery subscription on `klass` and block until SIGINT
35
+ # (Ctrl-C). Intended for REPL / console use — emits arriving events
36
+ # to `$stdout` by default, or yields each one to a caller-supplied
37
+ # block.
38
+ #
39
+ # @example Tail every event for a class
40
+ # Parse.watch(Post)
41
+ # # ^C to stop
42
+ #
43
+ # @example Tail with a query and a custom handler
44
+ # Parse.watch(Post, where: { category: "alerts" }) do |event, obj|
45
+ # puts "[#{event}] #{obj.title} (#{obj.id})"
46
+ # end
47
+ #
48
+ # @example Admin-style with master key (no ambient session)
49
+ # Parse.with_session(nil) { Parse.watch(JobRun) }
50
+ #
51
+ # @param klass [Class] a Parse::Object subclass.
52
+ # @param where [Hash] optional query constraints.
53
+ # @param on [Symbol, Array<Symbol>, nil] which event types to
54
+ # subscribe to (default: all of :create/:update/:delete/:enter/:leave).
55
+ # @param fields [Array<String>, nil] only fire updates when these
56
+ # fields change.
57
+ # @param session_token [String, nil] explicit override; defaults to
58
+ # `Parse.current_session_token`.
59
+ # @yield [event, object] called for each emitted event when a block
60
+ # is supplied. `event` is one of the watched symbols; `object` is
61
+ # the row.
62
+ # @return [Integer] the count of events delivered before the caller
63
+ # interrupted (Ctrl-C) or the subscription was torn down.
64
+ def watch(klass, where: {}, on: nil, fields: nil, session_token: nil, &block)
65
+ events = Array(on || DEFAULT_WATCH_EVENTS).map(&:to_sym)
66
+ printer = block_given? ? block : ->(ev, obj) {
67
+ title = obj.respond_to?(:id) ? obj.id : obj.inspect
68
+ puts "[#{Time.now.iso8601}] #{klass.parse_class}.#{ev} #{title}"
69
+ }
70
+
71
+ delivered = 0
72
+ counter_lock = Monitor.new
73
+ sub = _open_subscription(klass, where: where, fields: fields, session_token: session_token)
74
+
75
+ events.each do |ev|
76
+ sub.on(ev) do |obj, _original = nil|
77
+ counter_lock.synchronize { delivered += 1 }
78
+ begin
79
+ printer.call(ev, obj)
80
+ rescue StandardError => e
81
+ warn "[Parse.watch] handler raised #{e.class}: #{e.message}"
82
+ end
83
+ end
84
+ end
85
+ sub.on(:error) { |err| warn "[Parse.watch] error: #{err}" }
86
+
87
+ _block_until_interrupt
88
+ delivered
89
+ ensure
90
+ sub.unsubscribe if sub && sub.respond_to?(:unsubscribe)
91
+ end
92
+
93
+ # Block until a row matching the predicate arrives via LiveQuery,
94
+ # then return that row. Useful for `wait until the job flips`,
95
+ # `wait until the inbox row lands`, integration tests, etc.
96
+ #
97
+ # By default the first `:create`/`:enter` event resolves the wait.
98
+ # Pass a block to require the event also satisfy a predicate —
99
+ # `wait_for(Job, where: { kind: "import" }) { |j| j.status == "done" }`
100
+ # will keep waiting until both the query matches AND the block
101
+ # returns truthy.
102
+ #
103
+ # @example Wait for the first row of a class
104
+ # first = Parse.wait_for(Notification)
105
+ #
106
+ # @example Wait with a query and a predicate
107
+ # done = Parse.wait_for(Job, where: { kind: "import" },
108
+ # timeout: 60) { |j| j.status == "done" }
109
+ #
110
+ # @param klass [Class] a Parse::Object subclass.
111
+ # @param where [Hash] optional query constraints applied server-side.
112
+ # @param on [Symbol, Array<Symbol>] which event types to count
113
+ # (default: [:create, :enter]; pass :update for status-flip
114
+ # watching).
115
+ # @param timeout [Numeric, nil] seconds to wait. nil = forever.
116
+ # @param fields [Array<String>, nil] field filter for update events.
117
+ # @param session_token [String, nil] explicit override; defaults to
118
+ # `Parse.current_session_token`.
119
+ # @yield [object] optional predicate; must return truthy to resolve.
120
+ # @return [Parse::Object] the matched row.
121
+ # @raise [Timeout::Error] when `timeout:` elapses with no match.
122
+ def wait_for(klass, where: {}, on: nil, timeout: nil, fields: nil,
123
+ session_token: nil, &predicate)
124
+ events = Array(on || DEFAULT_WAIT_FOR_EVENTS).map(&:to_sym)
125
+ queue = Queue.new
126
+ sub = _open_subscription(klass, where: where, fields: fields, session_token: session_token)
127
+
128
+ events.each do |ev|
129
+ sub.on(ev) do |obj, _original = nil|
130
+ begin
131
+ next if predicate && !predicate.call(obj)
132
+ queue << obj
133
+ rescue StandardError => e
134
+ queue << e
135
+ end
136
+ end
137
+ end
138
+ sub.on(:error) { |err| queue << err }
139
+
140
+ result = if timeout
141
+ Timeout.timeout(timeout) { queue.pop }
142
+ else
143
+ queue.pop
144
+ end
145
+
146
+ raise result if result.is_a?(Exception)
147
+ result
148
+ ensure
149
+ sub.unsubscribe if sub && sub.respond_to?(:unsubscribe)
150
+ end
151
+
152
+ # @!visibility private
153
+ def _open_subscription(klass, where:, fields:, session_token:)
154
+ unless klass.respond_to?(:subscribe)
155
+ raise ArgumentError, "#{klass.inspect} does not implement .subscribe — pass a Parse::Object subclass"
156
+ end
157
+ klass.subscribe(where: where, fields: fields, session_token: session_token)
158
+ end
159
+
160
+ # @!visibility private
161
+ #
162
+ # Block the current thread until SIGINT (Ctrl-C) is received.
163
+ # We install a one-shot handler that releases a sleeping queue.pop
164
+ # and restore the prior handler on exit so library users can wrap
165
+ # `watch` inside their own signal-handling code without losing it.
166
+ def _block_until_interrupt
167
+ gate = Queue.new
168
+ prior = Signal.trap("INT") { gate << :interrupted }
169
+ gate.pop
170
+ ensure
171
+ Signal.trap("INT", prior || "DEFAULT") if defined?(prior)
172
+ end
173
+ end
174
+
175
+ class << self
176
+ # @see Parse::Console.watch
177
+ def watch(klass, **kwargs, &block)
178
+ Console.watch(klass, **kwargs, &block)
179
+ end
180
+
181
+ # @see Parse::Console.wait_for
182
+ def wait_for(klass, **kwargs, &block)
183
+ Console.wait_for(klass, **kwargs, &block)
184
+ end
185
+ end
186
+
187
+ class Object < Pointer
188
+ class << self
189
+ # Tail this class as LiveQuery events arrive — blocking, Ctrl-C
190
+ # to stop. See {Parse::Console.watch}.
191
+ def watch(**kwargs, &block)
192
+ Parse::Console.watch(self, **kwargs, &block)
193
+ end
194
+
195
+ # Block until the first row matching {where} (and an optional
196
+ # predicate block) arrives via LiveQuery. See
197
+ # {Parse::Console.wait_for}.
198
+ def wait_for(**kwargs, &block)
199
+ Parse::Console.wait_for(self, **kwargs, &block)
200
+ end
201
+ end
202
+ end
203
+ end