parse-stack-next 5.5.6 → 5.7.0
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/CHANGELOG.md +634 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +17 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +167 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/binding_audit.rb +211 -0
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +137 -0
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +40 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +170 -0
- data/lib/parse/embeddings/video_source.rb +121 -0
- data/lib/parse/embeddings/voyage.rb +554 -143
- data/lib/parse/embeddings.rb +83 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +97 -15
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +43 -19
- data/lib/parse/model/core/fetching.rb +2 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +39 -3
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +34 -8
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +132 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +166 -33
- data/lib/parse/vector_search.rb +141 -13
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +22 -4
- data/parse-stack-next.gemspec +6 -6
- metadata +14 -1
data/lib/parse/vector_search.rb
CHANGED
|
@@ -95,6 +95,44 @@ module Parse
|
|
|
95
95
|
# one. Atlas's guidance: numCandidates ≥ 10 × limit, ≤ 10_000.
|
|
96
96
|
DEFAULT_NUM_CANDIDATES_MULTIPLIER = 20
|
|
97
97
|
|
|
98
|
+
# How far past `k` the `$vectorSearch.limit` is raised when
|
|
99
|
+
# something downstream can drop rows (ACL enforcement, a
|
|
100
|
+
# caller-supplied `filter`, `protectedFields`, or pointer-field
|
|
101
|
+
# filtering). Atlas applies `limit` BEFORE any of those run, so
|
|
102
|
+
# without overfetching a caller who can read 2 of the top 10 asks
|
|
103
|
+
# for 10 and receives 2 — even when hundreds of readable matches
|
|
104
|
+
# exist further down the ranking.
|
|
105
|
+
#
|
|
106
|
+
# This is a mitigation, not a guarantee: a sufficiently selective
|
|
107
|
+
# ACL can still exhaust any finite candidate window. Deterministic
|
|
108
|
+
# fill would require `_rperm` declared as `type: "filter"` in the
|
|
109
|
+
# Atlas index (so the prefilter enforces visibility) or iterative
|
|
110
|
+
# candidate expansion. The instrumentation emitted by {.search}
|
|
111
|
+
# exists so an underfill is observable rather than silent.
|
|
112
|
+
DEFAULT_CANDIDATE_MULTIPLIER = 10
|
|
113
|
+
|
|
114
|
+
# Ceiling on the internally-raised candidate window. Atlas caps
|
|
115
|
+
# `numCandidates` at 10_000 and `numCandidates` must be >= `limit`,
|
|
116
|
+
# so the candidate window cannot usefully exceed that.
|
|
117
|
+
MAX_CANDIDATE_LIMIT = 10_000
|
|
118
|
+
|
|
119
|
+
# Emitted once per {.search}. The counts are deliberately named for
|
|
120
|
+
# where they are actually measured — there is no cheap way to learn
|
|
121
|
+
# how many rows `$vectorSearch` emitted before the server-side
|
|
122
|
+
# `$match` stages ran, so no field claims to be that number:
|
|
123
|
+
#
|
|
124
|
+
# * `candidate_limit` / `num_candidates` — the requested window.
|
|
125
|
+
# * `post_filter_count` — rows returned by the pipeline, i.e. after
|
|
126
|
+
# the server-side ACL `$match` and any caller `filter`.
|
|
127
|
+
# * `post_pointer_count` — rows left after client-side redaction and
|
|
128
|
+
# pointer-field filtering.
|
|
129
|
+
# * `returned_count` — rows handed back, after trimming to `k`.
|
|
130
|
+
# * `underfilled` — the caller received fewer than `k`.
|
|
131
|
+
#
|
|
132
|
+
# Obtaining a true pre-`$match` count would require a `$facet`, at
|
|
133
|
+
# the cost of a second pass over the candidate set.
|
|
134
|
+
AS_NOTIFICATION_NAME = "parse.vector_search.search"
|
|
135
|
+
|
|
98
136
|
# Accepted {.index_drift_policy} values.
|
|
99
137
|
INDEX_DRIFT_POLICIES = %i[warn raise ignore].freeze
|
|
100
138
|
|
|
@@ -130,6 +168,7 @@ module Parse
|
|
|
130
168
|
def index_drift_policy
|
|
131
169
|
@index_drift_policy ||= :warn
|
|
132
170
|
end
|
|
171
|
+
|
|
133
172
|
# Low-level `$vectorSearch` entry point.
|
|
134
173
|
#
|
|
135
174
|
# @param collection_name [String] Parse class name / Mongo
|
|
@@ -165,8 +204,9 @@ module Parse
|
|
|
165
204
|
# `_vscore` rather than `_score` so hybrid pipelines with
|
|
166
205
|
# Atlas Search don't collide on the same key).
|
|
167
206
|
def search(collection_name, field:, query_vector:, k: 10,
|
|
168
|
-
|
|
169
|
-
|
|
207
|
+
num_candidates: nil, candidate_limit: nil, filter: nil,
|
|
208
|
+
vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts)
|
|
209
|
+
candidate_limit_override = candidate_limit
|
|
170
210
|
require_available!
|
|
171
211
|
index_name = (index || @default_index)
|
|
172
212
|
if index_name.nil? || index_name.to_s.empty?
|
|
@@ -195,12 +235,61 @@ module Parse
|
|
|
195
235
|
raise ArgumentError, "k must be in 1..#{MAX_K} (got #{k_int})."
|
|
196
236
|
end
|
|
197
237
|
|
|
198
|
-
|
|
238
|
+
# Anything that can drop rows AFTER Atlas has already applied
|
|
239
|
+
# `limit` forces an overfetch, otherwise the caller silently
|
|
240
|
+
# receives fewer than `k`. Master mode with no caller filter
|
|
241
|
+
# has no attrition, so it keeps the old one-for-one cost.
|
|
242
|
+
attrition_possible = !resolution.master? || !(filter.nil? || filter.empty?)
|
|
243
|
+
candidate_limit = if candidate_limit_override
|
|
244
|
+
Integer(candidate_limit_override)
|
|
245
|
+
elsif attrition_possible
|
|
246
|
+
[k_int * DEFAULT_CANDIDATE_MULTIPLIER, MAX_CANDIDATE_LIMIT].min
|
|
247
|
+
else
|
|
248
|
+
k_int
|
|
249
|
+
end
|
|
250
|
+
if candidate_limit < k_int
|
|
251
|
+
raise ArgumentError,
|
|
252
|
+
"candidate_limit (#{candidate_limit}) must be >= k (#{k_int})."
|
|
253
|
+
end
|
|
254
|
+
if candidate_limit > MAX_CANDIDATE_LIMIT
|
|
255
|
+
raise ArgumentError,
|
|
256
|
+
"candidate_limit capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{candidate_limit})."
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ANN width stays anchored to `k`, NOT to the raised window.
|
|
260
|
+
# Deriving it from `candidate_limit` would multiply twice and
|
|
261
|
+
# silently widen the HNSW search by the candidate multiplier —
|
|
262
|
+
# a scoped k=10 would jump from 200 to 2000 candidates. The
|
|
263
|
+
# window only needs numCandidates to be at least as large as
|
|
264
|
+
# `limit`, so take whichever is greater.
|
|
265
|
+
derived_num_candidates =
|
|
266
|
+
[k_int * DEFAULT_NUM_CANDIDATES_MULTIPLIER, candidate_limit].max
|
|
267
|
+
num_candidates_int = (num_candidates || derived_num_candidates).to_i
|
|
268
|
+
num_candidates_int = MAX_CANDIDATE_LIMIT if num_candidates_int > MAX_CANDIDATE_LIMIT &&
|
|
269
|
+
num_candidates.nil?
|
|
270
|
+
|
|
271
|
+
# A caller-supplied num_candidates that predates the candidate
|
|
272
|
+
# window used to be valid whenever it was >= k. Clamping the
|
|
273
|
+
# implicit window down to it keeps those calls working rather
|
|
274
|
+
# than turning them into a new ArgumentError. An EXPLICIT
|
|
275
|
+
# candidate_limit still conflicts loudly — that combination can
|
|
276
|
+
# only be a mistake.
|
|
277
|
+
if num_candidates && num_candidates_int < candidate_limit
|
|
278
|
+
if candidate_limit_override
|
|
279
|
+
raise ArgumentError,
|
|
280
|
+
"num_candidates (#{num_candidates_int}) must be >= candidate_limit " \
|
|
281
|
+
"(#{candidate_limit})."
|
|
282
|
+
end
|
|
283
|
+
candidate_limit = [num_candidates_int, k_int].max
|
|
284
|
+
end
|
|
285
|
+
|
|
199
286
|
if num_candidates_int < k_int
|
|
200
|
-
raise ArgumentError,
|
|
287
|
+
raise ArgumentError,
|
|
288
|
+
"num_candidates (#{num_candidates_int}) must be >= k (#{k_int})."
|
|
201
289
|
end
|
|
202
|
-
if num_candidates_int >
|
|
203
|
-
raise ArgumentError,
|
|
290
|
+
if num_candidates_int > MAX_CANDIDATE_LIMIT
|
|
291
|
+
raise ArgumentError,
|
|
292
|
+
"num_candidates capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{num_candidates_int})."
|
|
204
293
|
end
|
|
205
294
|
|
|
206
295
|
validated_vector = validate_query_vector!(query_vector)
|
|
@@ -219,11 +308,13 @@ module Parse
|
|
|
219
308
|
)
|
|
220
309
|
|
|
221
310
|
vs_stage = {
|
|
222
|
-
"index"
|
|
223
|
-
"path"
|
|
224
|
-
"queryVector"
|
|
311
|
+
"index" => index_name.to_s,
|
|
312
|
+
"path" => path,
|
|
313
|
+
"queryVector" => validated_vector,
|
|
225
314
|
"numCandidates" => num_candidates_int,
|
|
226
|
-
|
|
315
|
+
# Deliberately the raised candidate window, not `k` — the
|
|
316
|
+
# result set is trimmed to `k` after enforcement runs.
|
|
317
|
+
"limit" => candidate_limit,
|
|
227
318
|
}
|
|
228
319
|
vs_stage["filter"] = vector_filter if vector_filter && !vector_filter.empty?
|
|
229
320
|
pipeline = [{ "$vectorSearch" => vs_stage }]
|
|
@@ -247,7 +338,11 @@ module Parse
|
|
|
247
338
|
|
|
248
339
|
pipeline << { "$match" => filter } if filter
|
|
249
340
|
|
|
250
|
-
raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms
|
|
341
|
+
raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
|
|
342
|
+
authorizing_client: Parse::ACLScope.client_of(resolution))
|
|
343
|
+
# Already past the server-side ACL `$match` and any caller
|
|
344
|
+
# `filter` — NOT the number $vectorSearch emitted.
|
|
345
|
+
post_filter_count = raw_results.length
|
|
251
346
|
|
|
252
347
|
# Post-fetch enforcement: walk the rows the same way
|
|
253
348
|
# Parse::MongoDB.aggregate would. Master mode skips every
|
|
@@ -266,9 +361,40 @@ module Parse
|
|
|
266
361
|
# every mode, master included, so `_hashed_password` /
|
|
267
362
|
# `_session_token` can never surface through this entry point.
|
|
268
363
|
raw_results.map! { |doc| Parse::PipelineSecurity.strip_internal_fields(doc) }
|
|
364
|
+
|
|
365
|
+
# Trim only AFTER every enforcement layer has run — that
|
|
366
|
+
# ordering is the whole point of the raised candidate window.
|
|
367
|
+
post_pointer_count = raw_results.length
|
|
368
|
+
raw_results = raw_results.first(k_int) if post_pointer_count > k_int
|
|
369
|
+
|
|
370
|
+
emit_search_stats(
|
|
371
|
+
collection_name: collection_name, k: k_int,
|
|
372
|
+
candidate_limit: candidate_limit, num_candidates: num_candidates_int,
|
|
373
|
+
post_filter_count: post_filter_count, post_pointer_count: post_pointer_count,
|
|
374
|
+
returned_count: raw_results.length, master: resolution.master?,
|
|
375
|
+
)
|
|
376
|
+
|
|
269
377
|
raw_results
|
|
270
378
|
end
|
|
271
379
|
|
|
380
|
+
# @!visibility private
|
|
381
|
+
# Emit per-search counts so ACL/filter attrition is observable.
|
|
382
|
+
# `underfilled` means the caller received fewer rows than they
|
|
383
|
+
# asked for — the signal worth alerting on, since it means either
|
|
384
|
+
# the candidate window was too small for this principal or the
|
|
385
|
+
# collection simply ran out of matches. Distinguishing those two
|
|
386
|
+
# would need a pre-`$match` count, which this deliberately does
|
|
387
|
+
# not fabricate.
|
|
388
|
+
def emit_search_stats(**payload)
|
|
389
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
390
|
+
|
|
391
|
+
payload[:pointer_attrition] =
|
|
392
|
+
payload[:post_filter_count] - payload[:post_pointer_count]
|
|
393
|
+
payload[:underfilled] = payload[:returned_count] < payload[:k]
|
|
394
|
+
ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload)
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
|
|
272
398
|
# Validate a query vector. Public so callers (and tests) can
|
|
273
399
|
# invoke it independently of {.search}.
|
|
274
400
|
#
|
|
@@ -359,10 +485,12 @@ module Parse
|
|
|
359
485
|
# `Parse::MongoDB.aggregate` because that helper prepends an
|
|
360
486
|
# ACL `$match` at stage 0, which Atlas rejects for any pipeline
|
|
361
487
|
# whose stage 0 is `$vectorSearch`.
|
|
362
|
-
def run_pipeline!(collection_name, pipeline, max_time_ms: nil)
|
|
488
|
+
def run_pipeline!(collection_name, pipeline, max_time_ms: nil, authorizing_client: nil)
|
|
363
489
|
agg_opts = {}
|
|
364
490
|
agg_opts[:max_time_ms] = max_time_ms if max_time_ms
|
|
365
|
-
|
|
491
|
+
# Vector search bypasses Parse::MongoDB.aggregate, so the binding
|
|
492
|
+
# guard only sees this read if the authorizing client arrives here.
|
|
493
|
+
coll = Parse::MongoDB.collection(collection_name, authorizing_client: authorizing_client)
|
|
366
494
|
coll.aggregate(pipeline, agg_opts).to_a
|
|
367
495
|
rescue => e
|
|
368
496
|
Parse::MongoDB.send(:raise_if_timeout!, e, collection_name, max_time_ms)
|
|
@@ -33,10 +33,10 @@ module Parse
|
|
|
33
33
|
def assert_webhook_url_safe!(url)
|
|
34
34
|
raise ArgumentError, "Webhook URL is required" if url.nil? || url.to_s.empty?
|
|
35
35
|
uri = begin
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
URI.parse(url.to_s)
|
|
37
|
+
rescue URI::InvalidURIError => e
|
|
38
|
+
raise ArgumentError, "Invalid webhook URL: #{e.message}"
|
|
39
|
+
end
|
|
40
40
|
unless %w[http https].include?(uri.scheme)
|
|
41
41
|
raise ArgumentError, "Webhook URL must be http(s) (got #{uri.scheme.inspect})"
|
|
42
42
|
end
|
|
@@ -51,12 +51,12 @@ module Parse
|
|
|
51
51
|
# inside the save handler (Parse Server has no create trigger); the webhook
|
|
52
52
|
# router runs the destroy chain inside the beforeDelete handler.
|
|
53
53
|
CALLBACK_TRIGGER_MAP = {
|
|
54
|
-
[:save,
|
|
55
|
-
[:create,
|
|
56
|
-
[:save,
|
|
57
|
-
[:create,
|
|
54
|
+
[:save, :before] => :before_save,
|
|
55
|
+
[:create, :before] => :before_save,
|
|
56
|
+
[:save, :after] => :after_save,
|
|
57
|
+
[:create, :after] => :after_save,
|
|
58
58
|
[:destroy, :before] => :before_delete,
|
|
59
|
-
[:destroy, :after]
|
|
59
|
+
[:destroy, :after] => :after_delete,
|
|
60
60
|
}.freeze
|
|
61
61
|
|
|
62
62
|
# ActiveModel callback chains + phases with NO server trigger that can run
|
|
@@ -67,10 +67,10 @@ module Parse
|
|
|
67
67
|
# trigger registration changes that. Surfaced as an informational note, not
|
|
68
68
|
# a fixable gap.
|
|
69
69
|
LOCAL_ONLY_MAP = {
|
|
70
|
-
[:update,
|
|
71
|
-
[:update,
|
|
70
|
+
[:update, :before] => :before_update,
|
|
71
|
+
[:update, :after] => :after_update,
|
|
72
72
|
[:validation, :before] => :before_validation,
|
|
73
|
-
[:validation, :after]
|
|
73
|
+
[:validation, :after] => :after_validation,
|
|
74
74
|
}.freeze
|
|
75
75
|
|
|
76
76
|
# The ActiveModel callback chains we introspect.
|
|
@@ -104,12 +104,12 @@ module Parse
|
|
|
104
104
|
|
|
105
105
|
def initialize(parse_class:, callbacks:, local_routes:, server_triggers:,
|
|
106
106
|
findings:, modeled:)
|
|
107
|
-
@parse_class
|
|
108
|
-
@callbacks
|
|
109
|
-
@local_routes
|
|
107
|
+
@parse_class = parse_class
|
|
108
|
+
@callbacks = callbacks
|
|
109
|
+
@local_routes = local_routes
|
|
110
110
|
@server_triggers = server_triggers
|
|
111
|
-
@findings
|
|
112
|
-
@modeled
|
|
111
|
+
@findings = findings
|
|
112
|
+
@modeled = modeled
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
# @return [Boolean] true when the class has at least one finding.
|
|
@@ -120,12 +120,12 @@ module Parse
|
|
|
120
120
|
# @return [Hash] a JSON-safe representation of this row.
|
|
121
121
|
def to_h
|
|
122
122
|
{
|
|
123
|
-
parse_class:
|
|
124
|
-
modeled:
|
|
125
|
-
callbacks:
|
|
126
|
-
local_routes:
|
|
123
|
+
parse_class: parse_class,
|
|
124
|
+
modeled: modeled,
|
|
125
|
+
callbacks: callbacks,
|
|
126
|
+
local_routes: local_routes,
|
|
127
127
|
server_triggers: server_triggers,
|
|
128
|
-
findings:
|
|
128
|
+
findings: findings,
|
|
129
129
|
}
|
|
130
130
|
end
|
|
131
131
|
end
|
|
@@ -144,11 +144,11 @@ module Parse
|
|
|
144
144
|
# callbacks (e.g. the `_User` default-ACL callback). Off by default to keep
|
|
145
145
|
# the report focused on app-defined logic.
|
|
146
146
|
def initialize(network: true, client: nil, include_framework: false)
|
|
147
|
-
@networked
|
|
147
|
+
@networked = network
|
|
148
148
|
@include_framework = include_framework
|
|
149
|
-
@client
|
|
150
|
-
@server_lookup
|
|
151
|
-
@classes
|
|
149
|
+
@client = client
|
|
150
|
+
@server_lookup = network ? fetch_server_triggers : {}
|
|
151
|
+
@classes = build_classes
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
# @return [Array<Hash>] every finding across all classes, flattened, with the
|
|
@@ -164,10 +164,11 @@ module Parse
|
|
|
164
164
|
def to_h
|
|
165
165
|
{
|
|
166
166
|
networked: networked,
|
|
167
|
-
classes:
|
|
168
|
-
summary:
|
|
167
|
+
classes: @classes.map(&:to_h),
|
|
168
|
+
summary: summary,
|
|
169
169
|
}
|
|
170
170
|
end
|
|
171
|
+
|
|
171
172
|
alias as_json to_h
|
|
172
173
|
|
|
173
174
|
# @return [Hash] finding counts keyed by kind, plus class totals.
|
|
@@ -175,9 +176,9 @@ module Parse
|
|
|
175
176
|
counts = Hash.new(0)
|
|
176
177
|
gaps.each { |g| counts[g[:kind]] += 1 }
|
|
177
178
|
{
|
|
178
|
-
classes_audited:
|
|
179
|
+
classes_audited: @classes.size,
|
|
179
180
|
classes_with_issues: @classes.count(&:issues?),
|
|
180
|
-
findings:
|
|
181
|
+
findings: counts,
|
|
181
182
|
}
|
|
182
183
|
end
|
|
183
184
|
|
|
@@ -212,6 +213,7 @@ module Parse
|
|
|
212
213
|
s[:findings].sort.each { |kind, n| lines << " #{kind}: #{n}" }
|
|
213
214
|
lines.join("\n")
|
|
214
215
|
end
|
|
216
|
+
|
|
215
217
|
alias to_s pretty
|
|
216
218
|
|
|
217
219
|
private
|
|
@@ -231,7 +233,7 @@ module Parse
|
|
|
231
233
|
lookup = Hash.new { |h, k| h[k] = {} }
|
|
232
234
|
client.triggers.results.each do |t|
|
|
233
235
|
next unless t["url"].present?
|
|
234
|
-
name
|
|
236
|
+
name = t["triggerName"]
|
|
235
237
|
klass = t[Parse::Model::KEY_CLASS_NAME] || t["className"]
|
|
236
238
|
next if name.blank? || klass.blank?
|
|
237
239
|
lookup[klass.to_s][name.to_s.underscore.to_sym] = t["url"]
|
|
@@ -262,18 +264,18 @@ module Parse
|
|
|
262
264
|
end
|
|
263
265
|
|
|
264
266
|
def audit_class(name)
|
|
265
|
-
klass
|
|
267
|
+
klass = name == "*" ? nil : (Parse::Model.find_class(name) rescue nil)
|
|
266
268
|
callbacks = klass ? collect_callbacks(klass) : {}
|
|
267
|
-
routes
|
|
268
|
-
server
|
|
269
|
-
findings
|
|
269
|
+
routes = collect_local_routes(name)
|
|
270
|
+
server = @server_lookup[name] || {}
|
|
271
|
+
findings = analyze(name, callbacks, routes, server)
|
|
270
272
|
ClassAudit.new(
|
|
271
|
-
parse_class:
|
|
272
|
-
callbacks:
|
|
273
|
-
local_routes:
|
|
273
|
+
parse_class: name,
|
|
274
|
+
callbacks: callbacks,
|
|
275
|
+
local_routes: routes,
|
|
274
276
|
server_triggers: server,
|
|
275
|
-
findings:
|
|
276
|
-
modeled:
|
|
277
|
+
findings: findings,
|
|
278
|
+
modeled: !klass.nil?,
|
|
277
279
|
)
|
|
278
280
|
end
|
|
279
281
|
|
|
@@ -373,7 +375,7 @@ module Parse
|
|
|
373
375
|
end
|
|
374
376
|
|
|
375
377
|
required.each do |trigger, cb_keys|
|
|
376
|
-
has_route
|
|
378
|
+
has_route = routes.include?(trigger)
|
|
377
379
|
has_server = server.key?(trigger)
|
|
378
380
|
missing = []
|
|
379
381
|
missing << :route unless has_route
|
|
@@ -381,7 +383,7 @@ module Parse
|
|
|
381
383
|
next if missing.empty?
|
|
382
384
|
|
|
383
385
|
findings << {
|
|
384
|
-
kind:
|
|
386
|
+
kind: :callbacks_inert,
|
|
385
387
|
trigger: trigger,
|
|
386
388
|
missing: missing,
|
|
387
389
|
callbacks: cb_keys.sort,
|
|
@@ -398,7 +400,7 @@ module Parse
|
|
|
398
400
|
# Wildcard-only coverage is reported on the "*" row, not here.
|
|
399
401
|
next unless Parse::Webhooks.routes[trigger]&.key?(name)
|
|
400
402
|
findings << {
|
|
401
|
-
kind:
|
|
403
|
+
kind: :route_not_registered,
|
|
402
404
|
trigger: trigger,
|
|
403
405
|
message: "Local `webhook :#{trigger}` block for #{name} is not " \
|
|
404
406
|
"registered as a server trigger — run register_triggers! " \
|
|
@@ -410,7 +412,7 @@ module Parse
|
|
|
410
412
|
server.each_key do |trigger|
|
|
411
413
|
next if routes.include?(trigger)
|
|
412
414
|
findings << {
|
|
413
|
-
kind:
|
|
415
|
+
kind: :orphan_server_trigger,
|
|
414
416
|
trigger: trigger,
|
|
415
417
|
message: "Server trigger #{trigger} is registered for #{name} but no " \
|
|
416
418
|
"local webhook block handles it — every matching operation " \
|
|
@@ -426,7 +428,7 @@ module Parse
|
|
|
426
428
|
end
|
|
427
429
|
if local_only.any?
|
|
428
430
|
findings << {
|
|
429
|
-
kind:
|
|
431
|
+
kind: :local_only_callbacks,
|
|
430
432
|
callbacks: local_only.sort,
|
|
431
433
|
message: "#{name} has local-only callbacks (#{local_only.sort.join(", ")}) " \
|
|
432
434
|
"that no server trigger can run — they fire for Ruby-initiated " \
|
|
@@ -451,25 +453,24 @@ module Parse
|
|
|
451
453
|
|
|
452
454
|
def inert_message(name, trigger, missing, cb_keys)
|
|
453
455
|
callbacks = cb_keys.sort.join(", ")
|
|
454
|
-
reason =
|
|
455
|
-
if missing == [:route, :server]
|
|
456
|
+
reason = if missing == [:route, :server]
|
|
456
457
|
"neither a local `webhook :#{trigger}` block nor a server trigger is " \
|
|
457
|
-
|
|
458
|
+
"registered"
|
|
458
459
|
elsif missing == [:route]
|
|
459
460
|
"no local `webhook :#{trigger}` block is registered to handle it"
|
|
460
461
|
else # [:server]
|
|
461
462
|
"a local block exists but the #{trigger} server trigger is not registered"
|
|
462
463
|
end
|
|
463
464
|
"#{name} callbacks (#{callbacks}) will NOT run for non-Ruby clients: " \
|
|
464
|
-
|
|
465
|
+
"#{reason}. Register `webhook :#{trigger}` and run register_triggers!."
|
|
465
466
|
end
|
|
466
467
|
|
|
467
468
|
def finding_glyph(kind)
|
|
468
469
|
case kind
|
|
469
|
-
when :callbacks_inert
|
|
470
|
-
when :route_not_registered
|
|
470
|
+
when :callbacks_inert then "GAP "
|
|
471
|
+
when :route_not_registered then "GAP "
|
|
471
472
|
when :orphan_server_trigger then "WARN"
|
|
472
|
-
when :local_only_callbacks
|
|
473
|
+
when :local_only_callbacks then "note"
|
|
473
474
|
else " "
|
|
474
475
|
end
|
|
475
476
|
end
|
|
@@ -493,7 +494,7 @@ module Parse
|
|
|
493
494
|
# `pretty: true`.
|
|
494
495
|
def trigger_audit(pretty: false, network: true, client: nil, include_framework: false)
|
|
495
496
|
audit = TriggerAudit.new(
|
|
496
|
-
network: network, client: client, include_framework: include_framework
|
|
497
|
+
network: network, client: client, include_framework: include_framework,
|
|
497
498
|
)
|
|
498
499
|
pretty ? audit.pretty : audit.to_h
|
|
499
500
|
end
|
data/lib/parse/webhooks.rb
CHANGED
|
@@ -182,8 +182,26 @@ module Parse
|
|
|
182
182
|
raise ArgumentError, "Invalid Webhook registration trigger #{type} #{className}"
|
|
183
183
|
end
|
|
184
184
|
|
|
185
|
-
#
|
|
186
|
-
|
|
185
|
+
# Triggers whose handlers compose instead of replacing one another.
|
|
186
|
+
#
|
|
187
|
+
# `after_save` / `after_delete` have always accumulated. The remaining
|
|
188
|
+
# `after_*` triggers are added because the SDK itself now registers
|
|
189
|
+
# them for cache invalidation: without this, registering an internal
|
|
190
|
+
# `after_logout` handler would silently replace an application's own,
|
|
191
|
+
# with the winner decided by file load order and no warning.
|
|
192
|
+
#
|
|
193
|
+
# This is safe only for non-rejectable triggers. Parse Server ignores
|
|
194
|
+
# their response body, and {#call_route} normalizes their result to
|
|
195
|
+
# `true` regardless, so `.last` semantics are irrelevant.
|
|
196
|
+
# {REJECTABLE_NON_OBJECT_TRIGGERS} are deliberately excluded: a
|
|
197
|
+
# composite of those must deny if ANY handler denies, and folding with
|
|
198
|
+
# `.last` would discard an earlier rejection.
|
|
199
|
+
composable = type == :after_save || type == :after_delete ||
|
|
200
|
+
(NON_OBJECT_TRIGGERS.include?(type) &&
|
|
201
|
+
!REJECTABLE_NON_OBJECT_TRIGGERS.include?(type) &&
|
|
202
|
+
type.to_s.start_with?("after_"))
|
|
203
|
+
|
|
204
|
+
if composable
|
|
187
205
|
routes[type][className] ||= []
|
|
188
206
|
routes[type][className].push block
|
|
189
207
|
else
|
|
@@ -353,7 +371,7 @@ module Parse
|
|
|
353
371
|
if pre_obj.respond_to?(:apply_field_guards!)
|
|
354
372
|
pre_obj.apply_field_guards!(
|
|
355
373
|
master: payload.master? || false,
|
|
356
|
-
is_new: payload.original.blank
|
|
374
|
+
is_new: payload.original.blank?,
|
|
357
375
|
)
|
|
358
376
|
end
|
|
359
377
|
end
|
|
@@ -777,7 +795,7 @@ module Parse
|
|
|
777
795
|
else
|
|
778
796
|
if self.logging.present?
|
|
779
797
|
puts "[Webhooks] --> Could not find mapping route for " \
|
|
780
|
-
|
|
798
|
+
"#{Parse::Middleware::BodyBuilder.redact(payload.to_json)}"
|
|
781
799
|
end
|
|
782
800
|
end
|
|
783
801
|
|
data/parse-stack-next.gemspec
CHANGED
|
@@ -6,7 +6,7 @@ require "parse/stack/version"
|
|
|
6
6
|
Gem::Specification.new do |spec|
|
|
7
7
|
spec.name = "parse-stack-next"
|
|
8
8
|
spec.version = Parse::Stack::VERSION
|
|
9
|
-
spec.authors =
|
|
9
|
+
spec.authors = ["Adrian Curtin", "Anthony Persaud", "Henry Spindell"]
|
|
10
10
|
spec.email = ["adrian+parse-stack@neurosynq.net"]
|
|
11
11
|
|
|
12
12
|
spec.summary = %q{Parse Server SDK for Ruby — ORM, queries, auth, and MongoDB-direct access}
|
|
@@ -15,11 +15,11 @@ Gem::Specification.new do |spec|
|
|
|
15
15
|
spec.license = "MIT"
|
|
16
16
|
|
|
17
17
|
spec.metadata = {
|
|
18
|
-
"homepage_uri"
|
|
19
|
-
"source_code_uri"
|
|
20
|
-
"changelog_uri"
|
|
21
|
-
"bug_tracker_uri"
|
|
22
|
-
"documentation_uri"
|
|
18
|
+
"homepage_uri" => "https://github.com/neurosynq/parse-stack-next",
|
|
19
|
+
"source_code_uri" => "https://github.com/neurosynq/parse-stack-next",
|
|
20
|
+
"changelog_uri" => "https://github.com/neurosynq/parse-stack-next/blob/main/CHANGELOG.md",
|
|
21
|
+
"bug_tracker_uri" => "https://github.com/neurosynq/parse-stack-next/issues",
|
|
22
|
+
"documentation_uri" => "https://neurosynq.github.io/parse-stack-next/",
|
|
23
23
|
"rubygems_mfa_required" => "true",
|
|
24
24
|
}
|
|
25
25
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: parse-stack-next
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adrian Curtin
|
|
@@ -225,6 +225,7 @@ files:
|
|
|
225
225
|
- docs/_config.yml
|
|
226
226
|
- docs/acl_clp_guide.md
|
|
227
227
|
- docs/atlas_vector_search_guide.md
|
|
228
|
+
- docs/caching.md
|
|
228
229
|
- docs/client_sdk_guide.md
|
|
229
230
|
- docs/mcp_guide.md
|
|
230
231
|
- docs/mongodb_direct_guide.md
|
|
@@ -242,6 +243,7 @@ files:
|
|
|
242
243
|
- examples/webhook_server.rb
|
|
243
244
|
- lib/parse-stack-next.rb
|
|
244
245
|
- lib/parse-stack.rb
|
|
246
|
+
- lib/parse/access.rb
|
|
245
247
|
- lib/parse/acl_scope.rb
|
|
246
248
|
- lib/parse/agent.rb
|
|
247
249
|
- lib/parse/agent/approval_gate.rb
|
|
@@ -284,8 +286,15 @@ files:
|
|
|
284
286
|
- lib/parse/atlas_search/result.rb
|
|
285
287
|
- lib/parse/atlas_search/search_builder.rb
|
|
286
288
|
- lib/parse/atlas_search/session.rb
|
|
289
|
+
- lib/parse/authorization.rb
|
|
290
|
+
- lib/parse/cache/invalidation.rb
|
|
291
|
+
- lib/parse/cache/keyspace.rb
|
|
292
|
+
- lib/parse/cache/moneta_surface.rb
|
|
287
293
|
- lib/parse/cache/pool.rb
|
|
288
294
|
- lib/parse/cache/redis.rb
|
|
295
|
+
- lib/parse/cache/scoped_view.rb
|
|
296
|
+
- lib/parse/cache/sub_cache.rb
|
|
297
|
+
- lib/parse/cache/upstream_roles.rb
|
|
289
298
|
- lib/parse/client.rb
|
|
290
299
|
- lib/parse/client/authentication.rb
|
|
291
300
|
- lib/parse/client/batch.rb
|
|
@@ -301,16 +310,20 @@ files:
|
|
|
301
310
|
- lib/parse/console.rb
|
|
302
311
|
- lib/parse/embeddings.rb
|
|
303
312
|
- lib/parse/embeddings/batch_embedder.rb
|
|
313
|
+
- lib/parse/embeddings/binding_audit.rb
|
|
304
314
|
- lib/parse/embeddings/cache.rb
|
|
305
315
|
- lib/parse/embeddings/cohere.rb
|
|
306
316
|
- lib/parse/embeddings/fixture.rb
|
|
307
317
|
- lib/parse/embeddings/image_fetch.rb
|
|
308
318
|
- lib/parse/embeddings/jina.rb
|
|
309
319
|
- lib/parse/embeddings/local_http.rb
|
|
320
|
+
- lib/parse/embeddings/media_file.rb
|
|
310
321
|
- lib/parse/embeddings/openai.rb
|
|
311
322
|
- lib/parse/embeddings/provider.rb
|
|
312
323
|
- lib/parse/embeddings/qwen.rb
|
|
313
324
|
- lib/parse/embeddings/spend_cap.rb
|
|
325
|
+
- lib/parse/embeddings/streaming_body.rb
|
|
326
|
+
- lib/parse/embeddings/video_source.rb
|
|
314
327
|
- lib/parse/embeddings/voyage.rb
|
|
315
328
|
- lib/parse/graphql.rb
|
|
316
329
|
- lib/parse/graphql/scalars.rb
|