parse-stack-next 5.8.0 → 5.8.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +318 -0
  3. data/README.md +25 -19
  4. data/docs/caching.md +50 -0
  5. data/docs/mcp_guide.md +15 -3
  6. data/docs/webhooks_guide.md +59 -7
  7. data/lib/parse/acl_scope.rb +71 -9
  8. data/lib/parse/agent/describe.rb +8 -2
  9. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  10. data/lib/parse/agent.rb +205 -20
  11. data/lib/parse/atlas_search/index_manager.rb +5 -1
  12. data/lib/parse/atlas_search.rb +49 -1
  13. data/lib/parse/authorization.rb +235 -3
  14. data/lib/parse/cache/sub_cache.rb +26 -0
  15. data/lib/parse/client/authentication.rb +19 -1
  16. data/lib/parse/client/batch.rb +35 -2
  17. data/lib/parse/client.rb +26 -2
  18. data/lib/parse/clp_scope.rb +37 -3
  19. data/lib/parse/live_query/client.rb +72 -2
  20. data/lib/parse/lock_backend.rb +4 -3
  21. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  22. data/lib/parse/model/associations/has_many.rb +11 -0
  23. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  24. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  25. data/lib/parse/model/classes/role.rb +23 -0
  26. data/lib/parse/model/classes/session.rb +279 -19
  27. data/lib/parse/model/classes/user.rb +33 -12
  28. data/lib/parse/model/core/actions.rb +19 -4
  29. data/lib/parse/model/core/fetching.rb +29 -2
  30. data/lib/parse/model/core/field_guards.rb +16 -8
  31. data/lib/parse/model/object.rb +52 -1
  32. data/lib/parse/model/push.rb +11 -1
  33. data/lib/parse/mongodb.rb +19 -1
  34. data/lib/parse/query/constraint.rb +1 -1
  35. data/lib/parse/query/constraints.rb +31 -27
  36. data/lib/parse/query.rb +793 -55
  37. data/lib/parse/stack/version.rb +1 -1
  38. data/lib/parse/stack.rb +19 -0
  39. data/lib/parse/vector_search/hybrid.rb +74 -15
  40. data/lib/parse/vector_search.rb +214 -7
  41. data/lib/parse/webhooks/payload.rb +43 -0
  42. data/lib/parse/webhooks.rb +312 -9
  43. metadata +1 -1
@@ -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.8.0"
9
+ VERSION = "5.8.1"
10
10
  end
11
11
  end
data/lib/parse/stack.rb CHANGED
@@ -62,6 +62,25 @@ module Parse
62
62
  # the first attempt and gone by the retry; the fiber-local state lives
63
63
  # for the lifetime of the block).
64
64
  #
65
+ # Mongo-direct reads follow the same rule: an explicit `master: true`
66
+ # passed to `Parse::MongoDB.aggregate`, a `Parse::Query` direct terminal,
67
+ # Atlas Search, or vector search runs in the public scope inside the
68
+ # block (or raises `ACLRequired` when `require_session_token` is on), and
69
+ # the `parse.mongodb.aggregate` notification payload carries
70
+ # `master_dropped: true`. `Parse::AtlasSearch.faceted_search` refuses to
71
+ # run inside the block. A LiveQuery admin connection opened inside the
72
+ # block sends no master key; one that already connected with it stays
73
+ # elevated, because Parse Server authorizes per connection.
74
+ #
75
+ # The SDK's own metadata reads keep the master key inside the block: the
76
+ # class schemas and role graph it reads to enforce a scope, index
77
+ # statistics, and the Atlas Search index listing. Those requests are
78
+ # marked individually; the block's state is never lifted while they run.
79
+ #
80
+ # This is a guard against accidental master-key use, not an isolation
81
+ # boundary. Code inside the block can call {Parse.with_master_key}, and
82
+ # `Parse::MongoDB.find` and `Parse::MongoDB.indexes` take no scope at all.
83
+ #
65
84
  # @yield runs the block with master-key disabled
66
85
  # @return [Object] the block's return value
67
86
  # @example
@@ -399,9 +399,10 @@ module Parse
399
399
  # Build the native `$rankFusion` pipeline (without ACL/CLP
400
400
  # stages). Public-ish via {.native_pipeline} for snapshot tests;
401
401
  # the live path appends ACL enforcement in {#run_native}.
402
- def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights:)
403
- vsel = vector_search_stage(vec, oversample)
404
- lsel = lexical_search_stage(lex, oversample)
402
+ def build_rank_fusion_stage(lex, vec, oversample, k_constant:, weights:,
403
+ owner_vector_filter: nil, owner_match: nil)
404
+ vsel = vector_search_stage(vec, oversample, owner_filter: owner_vector_filter)
405
+ lsel = lexical_search_stage(lex, oversample, owner_match: owner_match)
405
406
  stage = {
406
407
  "input" => {
407
408
  "pipelines" => { "vector" => vsel, "lexical" => lsel },
@@ -437,19 +438,44 @@ module Parse
437
438
  # AFTER the ACL `$match`, so trimming to `k` there would
438
439
  # reintroduce the underfill the window exists to prevent. The
439
440
  # trim to `k` happens client-side once enforcement is done.
441
+ pointer_fields = resolve_pointer_fields!(collection_name, resolution)
440
442
  native_pipeline_for(lex, vec, candidate_window, resolution,
441
443
  k_constant: fusion[:k_constant] || DEFAULT_K_CONSTANT,
442
- weights: fusion[:weights], limit: candidate_window)
443
- end
444
-
445
- def native_pipeline_for(lex, vec, oversample, resolution, k_constant:, weights:, limit:)
446
- pipeline = [build_rank_fusion_stage(lex, vec, oversample, k_constant: k_constant, weights: weights)]
444
+ weights: fusion[:weights], limit: candidate_window,
445
+ pointer_fields: pointer_fields,
446
+ owner_vector_filter: owner_vector_filter(collection_name, vec, pointer_fields, resolution))
447
+ end
448
+
449
+ def native_pipeline_for(lex, vec, oversample, resolution, k_constant:, weights:, limit:,
450
+ pointer_fields: nil, owner_vector_filter: nil)
451
+ scoped = !(resolution.nil? || resolution.master?)
452
+ # Ownership inside each `$rankFusion` input as well as after it:
453
+ # every input is capped at `oversample`, so a final `$match`
454
+ # alone only sees the fused top rows and can underfill when
455
+ # other users' rows dominate both branches. The lexical input
456
+ # filters before its `$limit`; the vector input uses the
457
+ # `$vectorSearch.filter` prefilter when the owner path is
458
+ # filter-indexed and served (see Parse::VectorSearch).
459
+ owner_match = if scoped && pointer_fields
460
+ Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id)
461
+ end
462
+ pipeline = [build_rank_fusion_stage(lex, vec, oversample, k_constant: k_constant, weights: weights,
463
+ owner_vector_filter: (owner_vector_filter if scoped),
464
+ owner_match: owner_match)]
447
465
  # The fused RRF score is surfaced via `{ $meta: "score" }`
448
466
  # (a numeric), not "scoreDetails" (a breakdown document).
449
467
  pipeline << { "$addFields" => { "_hybrid_score" => { "$meta" => "score" } } }
450
468
  unless resolution.nil? || resolution.master?
451
469
  acl_match = Parse::ACLScope.match_stage_for(resolution)
452
470
  pipeline << acl_match if acl_match
471
+ # pointerFields / readUserFields ownership before `$limit`, so
472
+ # rows ranked above the caller's own rows cannot crowd them
473
+ # out of the page.
474
+ if pointer_fields
475
+ pipeline << {
476
+ "$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id),
477
+ }
478
+ end
453
479
  end
454
480
  pipeline << { "$sort" => { "_hybrid_score" => -1 } }
455
481
  pipeline << { "$limit" => limit }
@@ -491,8 +517,11 @@ module Parse
491
517
  )
492
518
  end
493
519
 
520
+ owner_filter = owner_vector_filter(collection_name, vec, pointer_fields, resolution)
494
521
  pipeline = native_pipeline_for(lex, vec, oversample, resolution,
495
- k_constant: k_constant, weights: weights, limit: oversample)
522
+ k_constant: k_constant, weights: weights, limit: oversample,
523
+ pointer_fields: pointer_fields,
524
+ owner_vector_filter: owner_filter)
496
525
  rows = run_pipeline!(collection_name, pipeline,
497
526
  authorizing_client: Parse::ACLScope.client_of(resolution))
498
527
 
@@ -516,6 +545,8 @@ module Parse
516
545
  rows.select! { |doc| native_row_visible?(doc, perms_set) }
517
546
  Parse::ACLScope.redact_results!(rows, resolution)
518
547
  Parse::CLPScope.redact_protected_fields!(rows, protected_fields) if protected_fields.any?
548
+ # Defense in depth: the pointerFields `$match` already ran
549
+ # before `$limit`, so this should drop nothing.
519
550
  if pointer_fields
520
551
  rows = Parse::CLPScope.filter_by_pointer_fields(rows, pointer_fields, resolution.user_id)
521
552
  end
@@ -535,7 +566,13 @@ module Parse
535
566
  rows
536
567
  rescue Parse::CLPScope::Denied
537
568
  raise
538
- rescue StandardError
569
+ rescue StandardError => e
570
+ # A refused owner prefilter means Atlas serves an index version
571
+ # without that filter path; stop pushing it down so the client
572
+ # fallback below (and later searches) run without it.
573
+ if owner_filter && Parse::VectorSearch.owner_prefilter_rejected?(e, owner_filter)
574
+ Parse::VectorSearch.owner_prefilter_unavailable!(collection_name, vector_index_name(vec))
575
+ end
539
576
  # Native execution failed (e.g. a cluster that probed as
540
577
  # supported but rejects this exact shape, or a transient error).
541
578
  # Fall back to the client-side path rather than failing the
@@ -543,7 +580,23 @@ module Parse
543
580
  nil
544
581
  end
545
582
 
546
- def vector_search_stage(vec, oversample)
583
+ # The owner prefilter for the native vector input, or nil when it
584
+ # cannot be pushed down (master, no pointerFields, or the owner
585
+ # path not filter-indexed in the index Atlas is serving).
586
+ def owner_vector_filter(collection_name, vec, pointer_fields, resolution)
587
+ return nil if resolution.nil? || resolution.master? || pointer_fields.nil?
588
+ Parse::VectorSearch.send(:owner_vector_prefilter, collection_name, vector_index_name(vec),
589
+ pointer_fields, resolution)
590
+ end
591
+
592
+ # The vector index the native input searches: the branch's
593
+ # `index:` or {Parse::VectorSearch.default_index}, the same
594
+ # fallback {Parse::VectorSearch.search} uses on the client path.
595
+ def vector_index_name(vec)
596
+ vec[:index] || Parse::VectorSearch.default_index
597
+ end
598
+
599
+ def vector_search_stage(vec, oversample, owner_filter: nil)
547
600
  # Parity with Parse::VectorSearch: Atlas requires
548
601
  # `numCandidates >= limit` and caps it at 10_000. The default
549
602
  # (`oversample * MULTIPLIER`) can blow past 10_000 for a large
@@ -554,19 +607,23 @@ module Parse
554
607
  num_candidates = (vec[:num_candidates] || oversample * Parse::VectorSearch::DEFAULT_NUM_CANDIDATES_MULTIPLIER).to_i
555
608
  num_candidates = [[num_candidates, oversample].max, 10_000].min
556
609
  stage = {
557
- "index" => vec[:index].to_s,
610
+ "index" => vector_index_name(vec).to_s,
558
611
  "path" => vec[:field].to_s,
559
612
  "queryVector" => vec[:query_vector],
560
613
  "numCandidates" => num_candidates,
561
614
  "limit" => oversample,
562
615
  }
563
- stage["filter"] = vec[:vector_filter] if vec[:vector_filter] && !vec[:vector_filter].empty?
616
+ caller_filter = vec[:vector_filter] if vec[:vector_filter] && !vec[:vector_filter].empty?
617
+ prefilter = [caller_filter, owner_filter].compact
618
+ unless prefilter.empty?
619
+ stage["filter"] = prefilter.size == 1 ? prefilter.first : { "$and" => prefilter }
620
+ end
564
621
  inner = [{ "$vectorSearch" => stage }]
565
622
  inner << { "$match" => vec[:filter] } if vec[:filter]
566
623
  inner
567
624
  end
568
625
 
569
- def lexical_search_stage(lex, oversample)
626
+ def lexical_search_stage(lex, oversample, owner_match: nil)
570
627
  require_relative "../atlas_search" if defined?(Parse::AtlasSearch::SearchBuilder).nil?
571
628
  builder = Parse::AtlasSearch::SearchBuilder.new(index_name: lex[:index])
572
629
  fields = lex[:fields]
@@ -575,7 +632,9 @@ module Parse
575
632
  else
576
633
  Array(fields).each { |f| builder.text(query: lex[:query], path: f.to_s, fuzzy: lex[:fuzzy]) }
577
634
  end
578
- inner = [builder.build, { "$limit" => oversample }]
635
+ inner = [builder.build]
636
+ inner << { "$match" => owner_match } if owner_match
637
+ inner << { "$limit" => oversample }
579
638
  inner << { "$match" => lex[:filter] } if lex[:filter]
580
639
  inner
581
640
  end
@@ -47,8 +47,10 @@ module Parse
47
47
  #
48
48
  # * CLP `find` boundary check — refuses calls the equivalent REST
49
49
  # find would refuse.
50
- # * Optional `pointerFields` post-filter — drops rows that don't
51
- # name the current user_id in the configured pointer fields.
50
+ # * `pointerFields` / `readUserFields` ownership `$match` after
51
+ # `$vectorSearch`, pushed into the `$vectorSearch.filter` prefilter
52
+ # when every owner pointer (`_p_<field>`) is declared as a
53
+ # `type: "filter"` field of the index.
52
54
  # * Post-`$vectorSearch` ACL `$match` injection (Parse Server's
53
55
  # `_rperm` predicate).
54
56
  # * Post-fetch `protectedFields` redaction.
@@ -124,9 +126,10 @@ module Parse
124
126
  #
125
127
  # * `candidate_limit` / `num_candidates` — the requested window.
126
128
  # * `post_filter_count` — rows returned by the pipeline, i.e. after
127
- # the server-side ACL `$match` and any caller `filter`.
129
+ # the server-side ACL `$match`, the pointerFields `$match`, and any
130
+ # caller `filter`.
128
131
  # * `post_pointer_count` — rows left after client-side redaction and
129
- # pointer-field filtering.
132
+ # the defense-in-depth pointer-field re-check.
130
133
  # * `returned_count` — rows handed back, after trimming to `k`.
131
134
  # * `underfilled` — the caller received fewer than `k`.
132
135
  #
@@ -137,6 +140,10 @@ module Parse
137
140
  # Accepted {.index_drift_policy} values.
138
141
  INDEX_DRIFT_POLICIES = %i[warn raise ignore].freeze
139
142
 
143
+ # Guards the lazy creation of the owner-prefilter negative cache mutex.
144
+ PREFILTER_MUTEX_INIT = Mutex.new
145
+ private_constant :PREFILTER_MUTEX_INIT
146
+
140
147
  class << self
141
148
  # Policy applied when first-query index verification (see
142
149
  # {Parse::Core::VectorSearchable}) finds the deployed Atlas
@@ -319,7 +326,14 @@ module Parse
319
326
  # result set is trimmed to `k` after enforcement runs.
320
327
  "limit" => candidate_limit,
321
328
  }
322
- vs_stage["filter"] = vector_filter if vector_filter && !vector_filter.empty?
329
+ caller_prefilter = vector_filter if vector_filter && !vector_filter.empty?
330
+ owner_prefilter = unless resolution.master?
331
+ owner_vector_prefilter(collection_name, index_name, pointer_fields, resolution)
332
+ end
333
+ prefilter = [caller_prefilter, owner_prefilter].compact
334
+ unless prefilter.empty?
335
+ vs_stage["filter"] = prefilter.size == 1 ? prefilter.first : { "$and" => prefilter }
336
+ end
323
337
  pipeline = [{ "$vectorSearch" => vs_stage }]
324
338
 
325
339
  pipeline << {
@@ -337,12 +351,39 @@ module Parse
337
351
  unless resolution.master?
338
352
  acl_match = Parse::ACLScope.match_stage_for(resolution)
339
353
  pipeline << acl_match if acl_match
354
+ # pointerFields / readUserFields ownership, server-side so it
355
+ # runs on the whole candidate window before the trim to `k`.
356
+ # When every owner pointer is filter-indexed it also ran as the
357
+ # `$vectorSearch.filter` prefilter above; this stage then drops
358
+ # nothing but still covers the array-of-pointers storage form.
359
+ if pointer_fields
360
+ pipeline << {
361
+ "$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id),
362
+ }
363
+ end
340
364
  end
341
365
 
342
366
  pipeline << { "$match" => filter } if filter
343
367
 
344
- raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
345
- authorizing_client: Parse::ACLScope.client_of(resolution))
368
+ authorizing_client = Parse::ACLScope.client_of(resolution)
369
+ raw_results = begin
370
+ run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
371
+ authorizing_client: authorizing_client)
372
+ rescue StandardError => e
373
+ raise unless owner_prefilter && owner_prefilter_rejected?(e, owner_prefilter)
374
+ # Atlas is serving an index version without the owner filter
375
+ # path (a rebuild in progress, or a stale cached definition).
376
+ # Stop pushing it down and rerun once without it; the
377
+ # ownership `$match` after `$vectorSearch` still enforces it.
378
+ owner_prefilter_unavailable!(collection_name, index_name)
379
+ if caller_prefilter
380
+ vs_stage["filter"] = caller_prefilter
381
+ else
382
+ vs_stage.delete("filter")
383
+ end
384
+ run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
385
+ authorizing_client: authorizing_client)
386
+ end
346
387
  # Already past the server-side ACL `$match` and any caller
347
388
  # `filter` — NOT the number $vectorSearch emitted.
348
389
  post_filter_count = raw_results.length
@@ -353,6 +394,8 @@ module Parse
353
394
  unless resolution.master?
354
395
  Parse::ACLScope.redact_results!(raw_results, resolution)
355
396
  Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
397
+ # Defense in depth: the pointerFields `$match` already ran in
398
+ # the pipeline, so this should drop nothing.
356
399
  if pointer_fields
357
400
  raw_results = Parse::CLPScope.filter_by_pointer_fields(
358
401
  raw_results, pointer_fields, resolution.user_id,
@@ -442,6 +485,52 @@ module Parse
442
485
  # @return [String, nil]
443
486
  attr_accessor :default_index
444
487
 
488
+ # @!visibility private
489
+ # Whether an error is Atlas refusing one of the owner prefilter's
490
+ # paths because the served index does not declare it as
491
+ # `type: "filter"`. Atlas words the refusal the same way for any
492
+ # path, so the message must name an owner path: a caller
493
+ # `vector_filter` on an unindexed field raises as it always did and
494
+ # does not switch the owner prefilter off for other callers.
495
+ # @param error [Exception]
496
+ # @param owner_prefilter [Hash] the owner clause that was pushed down.
497
+ # @return [Boolean]
498
+ def owner_prefilter_rejected?(error, owner_prefilter)
499
+ message = error.message.to_s
500
+ return false unless message.match?(PREFILTER_REJECTED_PATTERN)
501
+ owner_prefilter_paths(owner_prefilter).any? { |path| message.include?(path) }
502
+ end
503
+
504
+ # @!visibility private
505
+ # The `_p_<field>` paths an owner prefilter clause names.
506
+ # @param owner_prefilter [Hash]
507
+ # @return [Array<String>]
508
+ def owner_prefilter_paths(owner_prefilter)
509
+ return [] unless owner_prefilter.is_a?(Hash)
510
+ clauses = owner_prefilter.key?("$or") ? Array(owner_prefilter["$or"]) : [owner_prefilter]
511
+ clauses.flat_map { |c| c.is_a?(Hash) ? c.keys.map(&:to_s) : [] }.select { |k| k.start_with?("_p_") }
512
+ end
513
+
514
+ # @!visibility private
515
+ # Stop pushing the owner prefilter down for this index until the
516
+ # index cache TTL passes, and drop the cached index definition so
517
+ # the next lookup reads what Atlas now reports.
518
+ # @param collection_name [String]
519
+ # @param index_name [String, Symbol]
520
+ def owner_prefilter_unavailable!(collection_name, index_name)
521
+ require_relative "atlas_search"
522
+ Parse::AtlasSearch::IndexManager.clear_cache(collection_name)
523
+ mark_prefilter_unavailable(prefilter_cache_key(collection_name, index_name))
524
+ end
525
+
526
+ # @!visibility private
527
+ # Forget every negative prefilter lookup (tests, or after an
528
+ # operator fixes the index and does not want to wait out the TTL).
529
+ def clear_prefilter_cache!
530
+ prefilter_mutex.synchronize { @prefilter_unavailable = {} }
531
+ nil
532
+ end
533
+
445
534
  private
446
535
 
447
536
  def require_available!
@@ -507,6 +596,124 @@ module Parse
507
596
  label: "VectorSearch")
508
597
  end
509
598
 
599
+ # The pointerFields ownership constraint as a `$vectorSearch.filter`
600
+ # clause, or nil when it cannot be pushed down. Atlas only accepts a
601
+ # prefilter on paths declared `type: "filter"` in the index, so this
602
+ # applies only when every owner pointer's storage path
603
+ # (`_p_<field>`) is filter-indexed. A prefilter keeps other users'
604
+ # rows out of the candidate window entirely, so an owner-scoped
605
+ # caller is not underfilled by higher-ranked rows they cannot read.
606
+ # Any lookup failure returns nil: the post-`$vectorSearch` `$match`
607
+ # still enforces ownership, only the fill is weaker.
608
+ #
609
+ # The prefilter matches only the scalar `_p_<field>` storage form.
610
+ # CLP ownership also accepts an array of pointers stored under the
611
+ # bare field name, so pushing down on such a field would drop rows
612
+ # the caller owns. It is therefore used only when every owner field
613
+ # is declared as a scalar pointer on the local model.
614
+ def owner_vector_prefilter(collection_name, index_name, pointer_fields, resolution)
615
+ return nil if pointer_fields.nil? || pointer_fields.empty?
616
+ uid = resolution.user_id.to_s
617
+ return nil if uid.empty?
618
+ return nil unless scalar_pointer_fields?(collection_name, pointer_fields)
619
+ paths = pointer_fields.map { |f| "_p_#{f}" }
620
+ indexed = vector_filter_paths(collection_name, index_name)
621
+ return nil unless paths.all? { |path| indexed.include?(path) }
622
+ storage = "#{Parse::Model::CLASS_USER}$#{uid}"
623
+ clauses = paths.map { |path| { path => { "$eq" => storage } } }
624
+ clauses.size == 1 ? clauses.first : { "$or" => clauses }
625
+ end
626
+
627
+ # Whether every owner field is declared as a scalar pointer
628
+ # (`belongs_to`) on the local model for the collection. Read from the
629
+ # model's declared fields, so no schema request is made. An unknown
630
+ # class, an undeclared field, or an array field all answer false.
631
+ def scalar_pointer_fields?(collection_name, pointer_fields)
632
+ klass = Parse::Model.find_class(collection_name.to_s)
633
+ return false unless klass.respond_to?(:fields)
634
+ fields = klass.fields
635
+ pointer_fields.all? { |f| fields[f.to_s.to_sym] == :pointer }
636
+ rescue StandardError
637
+ false
638
+ end
639
+
640
+ # Paths the named vectorSearch index declares as `type: "filter"`.
641
+ # Read through {Parse::AtlasSearch::IndexManager}, which caches the
642
+ # `$listSearchIndexes` result. Empty unless the index is READY and
643
+ # serving its latest definition: during a rebuild
644
+ # `latestDefinition` already lists the new filter path while Atlas
645
+ # still serves the old version, and a prefilter on that path is
646
+ # refused. A lookup error is remembered for the index cache TTL so a
647
+ # deployment without the `listSearchIndexes` privilege does not pay
648
+ # a failing round trip on every search.
649
+ def vector_filter_paths(collection_name, index_name)
650
+ key = prefilter_cache_key(collection_name, index_name)
651
+ return Set.new if prefilter_unavailable?(key)
652
+ require_relative "atlas_search"
653
+ idx = begin
654
+ Parse::AtlasSearch::IndexManager.get_index(collection_name, index_name.to_s)
655
+ rescue StandardError
656
+ mark_prefilter_unavailable(key)
657
+ return Set.new
658
+ end
659
+ return Set.new unless idx && index_serving_latest?(idx)
660
+ defn = idx["latestDefinition"] || idx[:latestDefinition] || {}
661
+ Array(defn["fields"] || defn[:fields]).each_with_object(Set.new) do |f, set|
662
+ next unless (f["type"] || f[:type]).to_s == "filter"
663
+ set << (f["path"] || f[:path]).to_s
664
+ end
665
+ end
666
+
667
+ # Whether Atlas is serving the index's latest definition: status
668
+ # READY and, when per-host `statusDetail` is reported, every host's
669
+ # main index at `latestDefinitionVersion`. Missing status fails
670
+ # toward "not serving" so the prefilter is simply skipped.
671
+ def index_serving_latest?(idx)
672
+ return false unless (idx["status"] || idx[:status]).to_s.upcase == "READY"
673
+ latest = definition_version(idx["latestDefinitionVersion"] || idx[:latestDefinitionVersion])
674
+ details = Array(idx["statusDetail"] || idx[:statusDetail])
675
+ return true if latest.nil? || details.empty?
676
+ details.all? do |detail|
677
+ main = detail["mainIndex"] || detail[:mainIndex]
678
+ main && definition_version(main["definitionVersion"] || main[:definitionVersion]) == latest
679
+ end
680
+ end
681
+
682
+ def definition_version(value)
683
+ return nil unless value.is_a?(Hash)
684
+ value["version"] || value[:version]
685
+ end
686
+
687
+ PREFILTER_REJECTED_PATTERN = /indexed as (?:a )?filter/i
688
+ private_constant :PREFILTER_REJECTED_PATTERN
689
+
690
+ def prefilter_cache_key(collection_name, index_name)
691
+ "#{collection_name}\x1f#{index_name}"
692
+ end
693
+
694
+ def prefilter_mutex
695
+ @prefilter_mutex ||= PREFILTER_MUTEX_INIT.synchronize { @prefilter_mutex ||= Mutex.new }
696
+ end
697
+
698
+ def prefilter_unavailable?(key)
699
+ prefilter_mutex.synchronize do
700
+ expires = (@prefilter_unavailable ||= {})[key]
701
+ next false unless expires
702
+ next true if Process.clock_gettime(Process::CLOCK_MONOTONIC) < expires
703
+ @prefilter_unavailable.delete(key)
704
+ false
705
+ end
706
+ end
707
+
708
+ def mark_prefilter_unavailable(key)
709
+ require_relative "atlas_search"
710
+ ttl = Parse::AtlasSearch::IndexManager.cache_ttl.to_f
711
+ return if ttl <= 0
712
+ prefilter_mutex.synchronize do
713
+ (@prefilter_unavailable ||= {})[key] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
714
+ end
715
+ end
716
+
510
717
  # Execute the pipeline directly against the MongoDB collection.
511
718
  # Mirrors `Parse::AtlasSearch#run_atlas_pipeline!` — bypasses
512
719
  # `Parse::MongoDB.aggregate` because that helper prepends an
@@ -557,6 +557,49 @@ module Parse
557
557
  # object from @object/@original).
558
558
  return @parse_object if defined?(@parse_object) && !@parse_object.nil?
559
559
  @parse_object = build_parse_object
560
+ # On a beforeSave create, note when the handler assigns an ACL. The
561
+ # model's dirty tracking ignores an assignment equal to the default
562
+ # stamp (`obj.acl = Parse::ACL.new` under a `{}` default), and the
563
+ # reply must still treat it as the handler's explicit choice.
564
+ if before_save? && @original.blank? && @parse_object.is_a?(Parse::Object)
565
+ @parse_object.extend(HandlerAclAssignment)
566
+ end
567
+ @parse_object
568
+ end
569
+
570
+ # Marks an object whose handler assigned its ACL. Used only on the
571
+ # object a beforeSave create handler receives.
572
+ # @!visibility private
573
+ module HandlerAclAssignment
574
+ def acl=(value)
575
+ @_webhook_handler_acl_assigned = true
576
+ super
577
+ end
578
+
579
+ # Mass assignment (`attributes=`, `apply_attributes!`) sets the ACL
580
+ # through these setters rather than `acl=`: `acl` by its Ruby name,
581
+ # `ACL` (an alias bound at class definition) by its remote name.
582
+ def acl_set_attribute!(*args, **kwargs, &block)
583
+ @_webhook_handler_acl_assigned = true
584
+ super
585
+ end
586
+
587
+ def ACL_set_attribute!(*args, **kwargs, &block)
588
+ @_webhook_handler_acl_assigned = true
589
+ super
590
+ end
591
+
592
+ # Forget assignments made before the handler ran (the field-guard
593
+ # pre-step reverts a client ACL through `acl=`).
594
+ # @!visibility private
595
+ def reset_webhook_handler_acl_assigned!
596
+ @_webhook_handler_acl_assigned = false
597
+ end
598
+
599
+ # @return [Boolean] whether the handler assigned the ACL.
600
+ def webhook_handler_acl_assigned?
601
+ @_webhook_handler_acl_assigned == true
602
+ end
560
603
  end
561
604
 
562
605
  # @!visibility private