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/client/caching.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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, {
|
|
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
|