parse-stack-next 5.6.0 → 5.7.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
@@ -224,8 +224,7 @@ module Parse
224
224
  chunker ||= default_chunker
225
225
  text_wire = wire_name(klass, resolved_text_field)
226
226
 
227
- raw_hits =
228
- if hybrid
227
+ raw_hits = if hybrid
229
228
  fetch_hybrid_hits(klass, query, k, field, filter, merged_vector_filter,
230
229
  tenant_scope, hybrid, scope_opts)
231
230
  else
@@ -253,8 +252,8 @@ module Parse
253
252
  tenant_scope, hybrid, scope_opts)
254
253
  cfg = hybrid.is_a?(Hash) ? hybrid : {}
255
254
  lexical = (cfg[:lexical] || cfg["lexical"] || {}).dup
256
- vector = (cfg[:vector] || cfg["vector"] || {}).dup
257
- fusion = cfg[:fusion] || cfg["fusion"]
255
+ vector = (cfg[:vector] || cfg["vector"] || {}).dup
256
+ fusion = cfg[:fusion] || cfg["fusion"]
258
257
 
259
258
  lexical[:query] ||= query
260
259
  # Tenant scope must be AUTHORITATIVE in BOTH branches. The previous
@@ -278,8 +277,7 @@ module Parse
278
277
 
279
278
  # @!visibility private
280
279
  def resolve_class!(klass)
281
- resolved =
282
- case klass
280
+ resolved = case klass
283
281
  when nil
284
282
  nil
285
283
  when Class
@@ -110,7 +110,7 @@ module Parse
110
110
  managed, ours = partition_parse_managed(existing)
111
111
  to_create, in_sync, conflicts = diff_declarations(declared, ours)
112
112
  declared_names = declared.map { |d| d[:options][:name] }.compact.to_set
113
- declared_sigs = declared.map { |d| key_sig(d[:keys]) }.to_set
113
+ declared_sigs = declared.map { |d| key_sig(d[:keys]) }.to_set
114
114
  orphans = ours.reject do |idx|
115
115
  declared_sigs.include?(key_sig(idx["key"] || idx[:key])) ||
116
116
  declared_names.include?(idx["name"] || idx[:name])
@@ -122,21 +122,21 @@ module Parse
122
122
  after_with_drop = after_no_drop - orphans.size
123
123
 
124
124
  {
125
- collection: collection,
126
- declared: declared,
127
- existing: existing,
128
- parse_managed: managed.map { |i| i["name"] || i[:name] },
129
- to_create: to_create,
130
- in_sync: in_sync,
131
- conflicts: conflicts,
132
- orphans: orphans.map { |i| i["name"] || i[:name] }.compact,
133
- capacity_used: used,
134
- capacity_after: after_no_drop,
135
- capacity_remaining: max - after_no_drop,
136
- capacity_ok: after_no_drop <= max,
125
+ collection: collection,
126
+ declared: declared,
127
+ existing: existing,
128
+ parse_managed: managed.map { |i| i["name"] || i[:name] },
129
+ to_create: to_create,
130
+ in_sync: in_sync,
131
+ conflicts: conflicts,
132
+ orphans: orphans.map { |i| i["name"] || i[:name] }.compact,
133
+ capacity_used: used,
134
+ capacity_after: after_no_drop,
135
+ capacity_remaining: max - after_no_drop,
136
+ capacity_ok: after_no_drop <= max,
137
137
  capacity_after_with_drop: after_with_drop,
138
138
  capacity_remaining_with_drop: max - after_with_drop,
139
- capacity_ok_with_drop: after_with_drop <= max,
139
+ capacity_ok_with_drop: after_with_drop <= max,
140
140
  }
141
141
  end
142
142
 
@@ -181,7 +181,7 @@ module Parse
181
181
  p[:orphans].each do |name|
182
182
  confirm = "drop:#{collection}:#{name}"
183
183
  res = Parse::MongoDB.drop_index(collection, name, confirm: confirm,
184
- allow_system_classes: collection.start_with?("_Join:"))
184
+ allow_system_classes: collection.start_with?("_Join:"))
185
185
  dropped << name if res == :dropped
186
186
  end
187
187
  end
@@ -190,21 +190,21 @@ module Parse
190
190
  result = Parse::MongoDB.create_index(
191
191
  collection,
192
192
  decl[:keys],
193
- name: decl[:options][:name],
194
- unique: decl[:options][:unique] == true,
195
- sparse: decl[:options][:sparse] == true,
196
- partial_filter: decl[:options][:partial_filter],
197
- expire_after: decl[:options][:expire_after],
193
+ name: decl[:options][:name],
194
+ unique: decl[:options][:unique] == true,
195
+ sparse: decl[:options][:sparse] == true,
196
+ partial_filter: decl[:options][:partial_filter],
197
+ expire_after: decl[:options][:expire_after],
198
198
  allow_system_classes: collection.start_with?("_Join:"),
199
199
  )
200
200
  (result == :exists ? skipped : created) << decl
201
201
  end
202
202
 
203
203
  {
204
- created: created,
204
+ created: created,
205
205
  skipped_exists: skipped,
206
- dropped: dropped,
207
- conflicts: p[:conflicts],
206
+ dropped: dropped,
207
+ conflicts: p[:conflicts],
208
208
  capacity_blocked: false,
209
209
  }
210
210
  end
@@ -235,12 +235,12 @@ module Parse
235
235
 
236
236
  def diff_declarations(declared, existing_ours)
237
237
  to_create = []
238
- in_sync = []
238
+ in_sync = []
239
239
  conflicts = []
240
240
 
241
241
  declared.each do |decl|
242
242
  decl_sig = key_sig(decl[:keys])
243
- named = decl[:options][:name]
243
+ named = decl[:options][:name]
244
244
 
245
245
  # Prefer a name match when the declaration named one — that's
246
246
  # the operator's authoritative target. Otherwise match by key
@@ -280,7 +280,7 @@ module Parse
280
280
  def serialize_existing(idx)
281
281
  {
282
282
  name: idx["name"] || idx[:name],
283
- key: idx["key"] || idx[:key],
283
+ key: idx["key"] || idx[:key],
284
284
  unique: idx["unique"] == true,
285
285
  sparse: idx["sparse"] == true,
286
286
  partial_filter: idx["partialFilterExpression"],
@@ -75,8 +75,8 @@ module Parse
75
75
  end
76
76
 
77
77
  to_create = []
78
- in_sync = []
79
- drifted = []
78
+ in_sync = []
79
+ drifted = []
80
80
 
81
81
  declared.each do |decl|
82
82
  target = existing_by_name[decl[:name]]
@@ -93,14 +93,14 @@ module Parse
93
93
  orphans = existing_by_name.keys.reject { |name| declared_names.include?(name) }
94
94
 
95
95
  {
96
- collection: coll,
97
- declared: declared,
98
- existing: existing,
96
+ collection: coll,
97
+ declared: declared,
98
+ existing: existing,
99
99
  atlas_available: available,
100
- to_create: to_create,
101
- in_sync: in_sync,
102
- drifted: drifted,
103
- orphans: orphans,
100
+ to_create: to_create,
101
+ in_sync: in_sync,
102
+ drifted: drifted,
103
+ orphans: orphans,
104
104
  }
105
105
  end
106
106
 
@@ -176,14 +176,14 @@ module Parse
176
176
  end
177
177
 
178
178
  {
179
- created: created,
180
- skipped_exists: skipped_exists,
181
- in_sync: p[:in_sync],
182
- updated: updated,
179
+ created: created,
180
+ skipped_exists: skipped_exists,
181
+ in_sync: p[:in_sync],
182
+ updated: updated,
183
183
  drifted_skipped: drifted_skipped,
184
- dropped: dropped,
184
+ dropped: dropped,
185
185
  orphans_skipped: orphans_skipped,
186
- wait_results: wait_results,
186
+ wait_results: wait_results,
187
187
  }
188
188
  end
189
189
 
@@ -321,10 +321,10 @@ module Parse
321
321
  # history, statusDetail) so operator-facing output stays readable.
322
322
  def serialize_existing(idx)
323
323
  {
324
- name: (idx["name"] || idx[:name]).to_s,
325
- status: (idx["status"] || idx[:status]).to_s,
326
- queryable: idx["queryable"] == true,
327
- latest_definition: idx["latestDefinition"] || idx[:latestDefinition],
324
+ name: (idx["name"] || idx[:name]).to_s,
325
+ status: (idx["status"] || idx[:status]).to_s,
326
+ queryable: idx["queryable"] == true,
327
+ latest_definition: idx["latestDefinition"] || idx[:latestDefinition],
328
328
  }
329
329
  end
330
330
 
@@ -110,7 +110,7 @@ module Parse
110
110
 
111
111
  batch_size = (ENV["BATCH_SIZE"] || "100").to_i
112
112
  batch_size = 100 if batch_size <= 0
113
- dry_run = ENV["DRY_RUN"].to_s.downcase == "true"
113
+ dry_run = ENV["DRY_RUN"].to_s.downcase == "true"
114
114
 
115
115
  if dry_run
116
116
  puts "[parse:references:populate] DRY_RUN=true — no writes will be issued"
@@ -120,7 +120,7 @@ module Parse
120
120
  fields = Array(klass._parse_reference_fields)
121
121
  fields.each do |field_name|
122
122
  populated_total = 0
123
- scanned_total = 0
123
+ scanned_total = 0
124
124
  loops_without_progress = 0
125
125
  loop do
126
126
  # Query for records where the reference column is null/
@@ -207,7 +207,7 @@ module Parse
207
207
  puts " to_create:"
208
208
  p[:to_create].each do |d|
209
209
  flags = d[:options].dup
210
- name = flags.delete(:name) || "(auto)"
210
+ name = flags.delete(:name) || "(auto)"
211
211
  puts " + #{d[:keys].inspect} name=#{name} opts=#{flags.inspect}"
212
212
  end
213
213
  end
@@ -423,13 +423,13 @@ module Parse
423
423
  end
424
424
 
425
425
  update = ENV["UPDATE"].to_s.downcase == "true"
426
- drop = ENV["DROP"].to_s.downcase == "true"
427
- wait = ENV["WAIT"].to_s.downcase == "true"
426
+ drop = ENV["DROP"].to_s.downcase == "true"
427
+ wait = ENV["WAIT"].to_s.downcase == "true"
428
428
  timeout = (ENV["WAIT_TIMEOUT"] || "600").to_i
429
429
  modes = []
430
430
  modes << "additive"
431
431
  modes << "update-drifted" if update
432
- modes << "drop-orphans" if drop
432
+ modes << "drop-orphans" if drop
433
433
  modes << "wait-for-ready (#{timeout}s)" if wait
434
434
  puts "[parse:mongo:search_indexes:apply] mode: #{modes.join(" + ")}"
435
435
  if drop
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.6.0"
9
+ VERSION = "5.7.1"
10
10
  end
11
11
  end
data/lib/parse/stack.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "stack/version"
5
5
  require_relative "client"
6
6
  require_relative "query"
7
7
  require_relative "model/object"
8
+ require_relative "access"
8
9
  require_relative "webhooks"
9
10
  require_relative "agent"
10
11
  require_relative "two_factor_auth"
@@ -303,7 +304,7 @@ module Parse
303
304
  "Parse.login: credentials rejected for #{username.inspect} (server returned no session)."
304
305
  end
305
306
  Fiber[SESSION_TOKEN_STATE_KEY] = user.session_token
306
- Fiber[CURRENT_USER_STATE_KEY] = user
307
+ Fiber[CURRENT_USER_STATE_KEY] = user
307
308
  user
308
309
  end
309
310
 
@@ -323,7 +324,7 @@ module Parse
323
324
  def self.logout(revoke: true)
324
325
  token = Fiber[SESSION_TOKEN_STATE_KEY]
325
326
  Fiber[SESSION_TOKEN_STATE_KEY] = nil
326
- Fiber[CURRENT_USER_STATE_KEY] = nil
327
+ Fiber[CURRENT_USER_STATE_KEY] = nil
327
328
  if revoke && token.is_a?(String) && !token.empty?
328
329
  begin
329
330
  Parse::Client.client.logout(token)
@@ -346,7 +347,7 @@ module Parse
346
347
  resolved = token.respond_to?(:session_token) ? token.session_token : token
347
348
  resolved = resolved.to_s if resolved
348
349
  Fiber[SESSION_TOKEN_STATE_KEY] = (resolved && !resolved.empty?) ? resolved : nil
349
- Fiber[CURRENT_USER_STATE_KEY] = nil
350
+ Fiber[CURRENT_USER_STATE_KEY] = nil
350
351
  Fiber[SESSION_TOKEN_STATE_KEY]
351
352
  end
352
353
 
@@ -721,17 +722,16 @@ module Parse
721
722
  next if duration_ms < threshold
722
723
  logger = respond_to?(:logger) ? Parse.logger : nil
723
724
  next unless logger
724
- detail =
725
- if name == "parse.mongodb.aggregate"
726
- "stages=#{payload[:stage_count]} types=#{Array(payload[:stage_types]).join(',')}"
725
+ detail = if name == "parse.mongodb.aggregate"
726
+ "stages=#{payload[:stage_count]} types=#{Array(payload[:stage_types]).join(",")}"
727
727
  else
728
- "filter=#{!!payload[:has_filter]} projection=#{Array(payload[:projection_keys]).join(',')}"
728
+ "filter=#{!!payload[:has_filter]} projection=#{Array(payload[:projection_keys]).join(",")}"
729
729
  end
730
730
  logger.warn(
731
731
  "[Parse::MongoDB] SLOW #{name} #{duration_ms}ms " \
732
- "collection=#{payload[:collection]} scope=#{payload[:scope] || 'n/a'} " \
733
- "#{detail} result_count=#{payload[:result_count] || 'n/a'} " \
734
- "max_time_ms=#{payload[:max_time_ms] || 'n/a'}",
732
+ "collection=#{payload[:collection]} scope=#{payload[:scope] || "n/a"} " \
733
+ "#{detail} result_count=#{payload[:result_count] || "n/a"} " \
734
+ "max_time_ms=#{payload[:max_time_ms] || "n/a"}",
735
735
  )
736
736
  end
737
737
  ActiveSupport::Notifications.subscribe("parse.mongodb.aggregate", &handler)
@@ -136,11 +136,11 @@ module Parse
136
136
  acc.values
137
137
  .sort_by { |e| [-e[:score], row_id(e[:doc]).to_s, e[:seq]] }
138
138
  .map do |e|
139
- row = e[:doc].dup
140
- row["_hybrid_score"] = e[:score]
141
- row["_hybrid_ranks"] = e[:ranks]
142
- row
143
- end
139
+ row = e[:doc].dup
140
+ row["_hybrid_score"] = e[:score]
141
+ row["_hybrid_ranks"] = e[:ranks]
142
+ row
143
+ end
144
144
  end
145
145
 
146
146
  # Detect whether the cluster backing `collection` supports the
@@ -156,14 +156,21 @@ module Parse
156
156
  # cached per collection for {PROBE_CACHE_TTL}.
157
157
  #
158
158
  # @param collection [String] Parse class / Mongo collection name.
159
+ # @param authorizing_client [Parse::Client, nil] client whose
160
+ # application binding must be checked before probing.
159
161
  # @return [Boolean]
160
- def rank_fusion_supported?(collection)
162
+ def rank_fusion_supported?(collection, authorizing_client: nil)
163
+ # Check on every call, including cache hits. Otherwise a verdict
164
+ # cached by application A could bypass the unidentified/mismatched
165
+ # caller guard when application B asks about the same collection.
166
+ Parse::MongoDB.verify_client!(authorizing_client)
167
+
161
168
  key = collection.to_s
162
169
  now = monotonic
163
170
  cached = probe_cache_get(key, now)
164
171
  return cached unless cached.nil?
165
172
 
166
- supported = run_probe(key)
173
+ supported = run_probe(key, authorizing_client: authorizing_client)
167
174
  probe_cache_put(key, supported, now)
168
175
  supported
169
176
  end
@@ -235,7 +242,7 @@ module Parse
235
242
  "hybrid search: fusion[:method] must be :rrf, :rrf_client, or :rrf_native (got #{method.inspect})."
236
243
  end
237
244
  k_constant = fusion[:k_constant] || DEFAULT_K_CONSTANT
238
- weights = fusion[:weights]
245
+ weights = fusion[:weights]
239
246
  # Two distinct numbers, deliberately not one. `fusion_depth` is
240
247
  # how many rows each branch RETAINS for RRF (bounded by
241
248
  # VectorSearch::MAX_K, since it becomes a branch `k`);
@@ -256,30 +263,39 @@ module Parse
256
263
  # two-aggregate client path unless a caller explicitly opts into
257
264
  # native AND the cluster supports it. Native still falls back to
258
265
  # the client path on any execution error.
259
- if method == :rrf_native && rank_fusion_supported?(collection_name)
260
- # The native pipeline enforces ACL AFTER fusion, so its
261
- # per-branch limit is the pre-ACL depth: the candidate
262
- # window, not the fusion depth.
263
- fused = run_native(collection_name, lex, vec, candidate_window,
264
- k_constant: k_constant, weights: weights, scope_opts: scope_opts)
265
- if fused
266
- trimmed = fused.first(k_int)
267
- # Native retains the full candidate window per branch,
268
- # NOT the client path's fusion depth: its ACL `$match`
269
- # runs after `$rankFusion`, so the branch limit and the
270
- # pre-ACL window are necessarily the same number. Report
271
- # what actually executed rather than the client figure.
272
- emit_hybrid_stats(collection_name: collection_name, k: k_int,
273
- method: :rrf_native, branch_depth: candidate_window,
274
- candidate_window: candidate_window,
275
- post_filter_count: fused.length,
276
- returned_count: trimmed.length)
277
- return trimmed
266
+ if method == :rrf_native
267
+ native_resolution = Parse::ACLScope.resolve!(
268
+ scope_opts.dup, method_name: :"VectorSearch::Hybrid.search",
269
+ )
270
+ if rank_fusion_supported?(
271
+ collection_name,
272
+ authorizing_client: Parse::ACLScope.client_of(native_resolution),
273
+ )
274
+ # The native pipeline enforces ACL AFTER fusion, so its
275
+ # per-branch limit is the pre-ACL depth: the candidate
276
+ # window, not the fusion depth.
277
+ fused = run_native(collection_name, lex, vec, candidate_window,
278
+ k_constant: k_constant, weights: weights, scope_opts: scope_opts,
279
+ resolution: native_resolution)
280
+ if fused
281
+ trimmed = fused.first(k_int)
282
+ # Native retains the full candidate window per branch,
283
+ # NOT the client path's fusion depth: its ACL `$match`
284
+ # runs after `$rankFusion`, so the branch limit and the
285
+ # pre-ACL window are necessarily the same number. Report
286
+ # what actually executed rather than the client figure.
287
+ emit_hybrid_stats(collection_name: collection_name, k: k_int,
288
+ method: :rrf_native, branch_depth: candidate_window,
289
+ candidate_window: candidate_window,
290
+ post_filter_count: fused.length,
291
+ returned_count: trimmed.length)
292
+ return trimmed
293
+ end
278
294
  end
279
295
  end
280
296
 
281
297
  lexical_rows = run_lexical(collection_name, lex, fusion_depth, scope_opts)
282
- vector_rows = run_vector(collection_name, vec, fusion_depth, candidate_window, scope_opts)
298
+ vector_rows = run_vector(collection_name, vec, fusion_depth, candidate_window, scope_opts)
283
299
  fused = rrf({ lexical: lexical_rows, vector: vector_rows },
284
300
  k_constant: k_constant, weights: weights)
285
301
  trimmed = fused.first(k_int)
@@ -310,8 +326,7 @@ module Parse
310
326
  #
311
327
  # @return [Array(Integer, Integer)] `[fusion_depth, candidate_window]`
312
328
  def resolve_windows(k_int, candidate_limit)
313
- window =
314
- if candidate_limit
329
+ window = if candidate_limit
315
330
  limit = Integer(candidate_limit)
316
331
  if limit < k_int
317
332
  raise ArgumentError,
@@ -441,8 +456,8 @@ module Parse
441
456
  pipeline
442
457
  end
443
458
 
444
- def run_native(collection_name, lex, vec, oversample, k_constant:, weights:, scope_opts:)
445
- resolution = Parse::ACLScope.resolve!(scope_opts.dup, method_name: :"VectorSearch::Hybrid.search")
459
+ def run_native(collection_name, lex, vec, oversample, k_constant:, weights:, scope_opts:, resolution: nil)
460
+ resolution ||= Parse::ACLScope.resolve!(scope_opts.dup, method_name: :"VectorSearch::Hybrid.search")
446
461
  assert_clp_find!(collection_name, resolution)
447
462
  pointer_fields = resolve_pointer_fields!(collection_name, resolution)
448
463
  protected_fields = Parse::CLPScope.protected_fields_for(
@@ -455,7 +470,8 @@ module Parse
455
470
 
456
471
  pipeline = native_pipeline_for(lex, vec, oversample, resolution,
457
472
  k_constant: k_constant, weights: weights, limit: oversample)
458
- rows = run_pipeline!(collection_name, pipeline)
473
+ rows = run_pipeline!(collection_name, pipeline,
474
+ authorizing_client: Parse::ACLScope.client_of(resolution))
459
475
 
460
476
  unless resolution.master?
461
477
  # Defense-in-depth top-level row gate. The in-pipeline ACL
@@ -515,11 +531,11 @@ module Parse
515
531
  num_candidates = (vec[:num_candidates] || oversample * Parse::VectorSearch::DEFAULT_NUM_CANDIDATES_MULTIPLIER).to_i
516
532
  num_candidates = [[num_candidates, oversample].max, 10_000].min
517
533
  stage = {
518
- "index" => vec[:index].to_s,
519
- "path" => vec[:field].to_s,
520
- "queryVector" => vec[:query_vector],
534
+ "index" => vec[:index].to_s,
535
+ "path" => vec[:field].to_s,
536
+ "queryVector" => vec[:query_vector],
521
537
  "numCandidates" => num_candidates,
522
- "limit" => oversample,
538
+ "limit" => oversample,
523
539
  }
524
540
  stage["filter"] = vec[:vector_filter] if vec[:vector_filter] && !vec[:vector_filter].empty?
525
541
  inner = [{ "$vectorSearch" => stage }]
@@ -543,10 +559,18 @@ module Parse
543
559
 
544
560
  # -- the $rankFusion support probe -------------------------------
545
561
 
546
- def run_probe(collection_name)
547
- coll = Parse::MongoDB.collection(collection_name)
562
+ # Capability probe only: runs `$rankFusion` with an empty input and a
563
+ # `$limit 0`, so it reads no rows. It still carries the authorizing
564
+ # client because the process-global MongoDB connection must enforce
565
+ # its application binding even for a zero-row probe.
566
+ def run_probe(collection_name, authorizing_client: nil)
567
+ coll = Parse::MongoDB.collection(collection_name, authorizing_client: authorizing_client)
548
568
  coll.aggregate([{ "$rankFusion" => { "input" => {} } }, { "$limit" => 0 }]).to_a
549
569
  true
570
+ rescue Parse::MongoDB::ClientMismatch
571
+ # A binding violation is not evidence that the stage exists. It is a
572
+ # security boundary and must reach the caller without being cached.
573
+ raise
550
574
  rescue StandardError => e
551
575
  # "Unknown aggregation stage $rankFusion" (or an unrecognized-
552
576
  # operator variant) means the cluster predates native support.
@@ -642,8 +666,9 @@ module Parse
642
666
  end
643
667
  end
644
668
 
645
- def run_pipeline!(collection_name, pipeline)
646
- Parse::MongoDB.collection(collection_name).aggregate(pipeline).to_a
669
+ def run_pipeline!(collection_name, pipeline, authorizing_client: nil)
670
+ Parse::MongoDB.collection(collection_name, authorizing_client: authorizing_client)
671
+ .aggregate(pipeline).to_a
647
672
  end
648
673
 
649
674
  def assert_clp_find!(collection_name, resolution)
@@ -168,6 +168,7 @@ module Parse
168
168
  def index_drift_policy
169
169
  @index_drift_policy ||= :warn
170
170
  end
171
+
171
172
  # Low-level `$vectorSearch` entry point.
172
173
  #
173
174
  # @param collection_name [String] Parse class name / Mongo
@@ -203,8 +204,8 @@ module Parse
203
204
  # `_vscore` rather than `_score` so hybrid pipelines with
204
205
  # Atlas Search don't collide on the same key).
205
206
  def search(collection_name, field:, query_vector:, k: 10,
206
- num_candidates: nil, candidate_limit: nil, filter: nil,
207
- vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts)
207
+ num_candidates: nil, candidate_limit: nil, filter: nil,
208
+ vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts)
208
209
  candidate_limit_override = candidate_limit
209
210
  require_available!
210
211
  index_name = (index || @default_index)
@@ -239,8 +240,7 @@ module Parse
239
240
  # receives fewer than `k`. Master mode with no caller filter
240
241
  # has no attrition, so it keeps the old one-for-one cost.
241
242
  attrition_possible = !resolution.master? || !(filter.nil? || filter.empty?)
242
- candidate_limit =
243
- if candidate_limit_override
243
+ candidate_limit = if candidate_limit_override
244
244
  Integer(candidate_limit_override)
245
245
  elsif attrition_possible
246
246
  [k_int * DEFAULT_CANDIDATE_MULTIPLIER, MAX_CANDIDATE_LIMIT].min
@@ -308,13 +308,13 @@ module Parse
308
308
  )
309
309
 
310
310
  vs_stage = {
311
- "index" => index_name.to_s,
312
- "path" => path,
313
- "queryVector" => validated_vector,
311
+ "index" => index_name.to_s,
312
+ "path" => path,
313
+ "queryVector" => validated_vector,
314
314
  "numCandidates" => num_candidates_int,
315
315
  # Deliberately the raised candidate window, not `k` — the
316
316
  # result set is trimmed to `k` after enforcement runs.
317
- "limit" => candidate_limit,
317
+ "limit" => candidate_limit,
318
318
  }
319
319
  vs_stage["filter"] = vector_filter if vector_filter && !vector_filter.empty?
320
320
  pipeline = [{ "$vectorSearch" => vs_stage }]
@@ -338,7 +338,8 @@ module Parse
338
338
 
339
339
  pipeline << { "$match" => filter } if filter
340
340
 
341
- 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))
342
343
  # Already past the server-side ACL `$match` and any caller
343
344
  # `filter` — NOT the number $vectorSearch emitted.
344
345
  post_filter_count = raw_results.length
@@ -484,10 +485,12 @@ module Parse
484
485
  # `Parse::MongoDB.aggregate` because that helper prepends an
485
486
  # ACL `$match` at stage 0, which Atlas rejects for any pipeline
486
487
  # whose stage 0 is `$vectorSearch`.
487
- def run_pipeline!(collection_name, pipeline, max_time_ms: nil)
488
+ def run_pipeline!(collection_name, pipeline, max_time_ms: nil, authorizing_client: nil)
488
489
  agg_opts = {}
489
490
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
490
- coll = Parse::MongoDB.collection(collection_name)
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)
491
494
  coll.aggregate(pipeline, agg_opts).to_a
492
495
  rescue => e
493
496
  Parse::MongoDB.send(:raise_if_timeout!, e, collection_name, max_time_ms)
@@ -718,6 +718,7 @@ module Parse
718
718
  @deferred_callbacks << block
719
719
  true
720
720
  end
721
+
721
722
  alias_method :defer, :after_response
722
723
 
723
724
  # @!visibility private
@@ -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
- URI.parse(url.to_s)
37
- rescue URI::InvalidURIError => e
38
- raise ArgumentError, "Invalid webhook URL: #{e.message}"
39
- end
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