parse-stack-next 5.4.1 → 5.5.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.
@@ -372,6 +372,17 @@ module Parse
372
372
  if pointer_fields
373
373
  rows = Parse::CLPScope.filter_by_pointer_fields(rows, pointer_fields, resolution.user_id)
374
374
  end
375
+ # NEW-VEC-1: the `$rankFusion` meta score is materialized
376
+ # BEFORE the ACL `$match`, so a surviving row's raw
377
+ # `_hybrid_score` encodes its rank among rows the caller
378
+ # cannot read — a cross-ACL inference channel for scoped
379
+ # callers probing with crafted queries. Recompute the
380
+ # surfaced score from the POST-filter ordering (the rows are
381
+ # already sorted by the true fused score, so relative order
382
+ # is preserved); the new value is a function of visible rows
383
+ # only. The client-side RRF path is unaffected — it ranks
384
+ # from already-filtered branch results.
385
+ recompute_scores_from_visible_order!(rows, k_constant: k_constant, weights: weights)
375
386
  end
376
387
  rows.map! { |doc| Parse::PipelineSecurity.strip_internal_fields(doc) }
377
388
  rows
@@ -443,10 +454,20 @@ module Parse
443
454
  # recognized-but-misused `$rankFusion` (or an unrelated auth/parse
444
455
  # error) is treated as supported and surfaces its real error on the
445
456
  # actual query rather than silently disabling native fusion.
457
+ #
458
+ # Deliberately narrow (NEW-VEC-2): a broad phrase like
459
+ # "is not allowed" also appears in MongoDB authorization errors
460
+ # ("not allowed to execute command aggregate"), which combined
461
+ # with the stage name in the message would misclassify an
462
+ # auth-failing cluster and cache the wrong probe verdict for
463
+ # PROBE_CACHE_TTL. Only phrases that unambiguously mean
464
+ # "this stage name is unknown to the parser" belong here; any
465
+ # other failure falls through to "supported" and the real query
466
+ # surfaces the real error (with the client path as fallback).
446
467
  UNSUPPORTED_STAGE_FRAGMENTS = [
447
468
  "unrecognized pipeline stage name",
448
469
  "unknown aggregation stage",
449
- "is not allowed",
470
+ "unknown stage",
450
471
  ].freeze
451
472
  private_constant :UNSUPPORTED_STAGE_FRAGMENTS
452
473
 
@@ -455,6 +476,23 @@ module Parse
455
476
  msg.include?("rankfusion") && UNSUPPORTED_STAGE_FRAGMENTS.any? { |f| msg.include?(f) }
456
477
  end
457
478
 
479
+ # @!visibility private
480
+ # Replace each visible row's `_hybrid_score` with an RRF-shaped
481
+ # score derived from its position AMONG VISIBLE ROWS:
482
+ # `Σ_b weight_b / (k_constant + visible_rank)`. Monotone with the
483
+ # original fused order (input is already score-sorted), but
484
+ # carries no information about how many hidden rows ranked above
485
+ # or between the visible ones. See NEW-VEC-1.
486
+ def recompute_scores_from_visible_order!(rows, k_constant:, weights:)
487
+ w = weights ? symbolize(weights) : nil
488
+ total_weight = weight_for(w, :lexical).to_f + weight_for(w, :vector).to_f
489
+ rows.each_with_index do |doc, i|
490
+ next unless doc.is_a?(Hash)
491
+ doc["_hybrid_score"] = total_weight / (k_constant + i + 1)
492
+ end
493
+ rows
494
+ end
495
+
458
496
  # -- probe cache -------------------------------------------------
459
497
 
460
498
  PROBE_MUTEX_INIT = Mutex.new
@@ -95,7 +95,41 @@ module Parse
95
95
  # one. Atlas's guidance: numCandidates ≥ 10 × limit, ≤ 10_000.
96
96
  DEFAULT_NUM_CANDIDATES_MULTIPLIER = 20
97
97
 
98
+ # Accepted {.index_drift_policy} values.
99
+ INDEX_DRIFT_POLICIES = %i[warn raise ignore].freeze
100
+
98
101
  class << self
102
+ # Policy applied when first-query index verification (see
103
+ # {Parse::Core::VectorSearchable}) finds the deployed Atlas
104
+ # vectorSearch index disagreeing with the model declaration —
105
+ # wrong `numDimensions`, wrong `similarity`, or a tenant-scope
106
+ # field missing from the index's `filter` paths.
107
+ #
108
+ # * `:warn` (default) — emit a `[Parse::VectorSearch:DRIFT]`
109
+ # warning once per (class, field, index) and continue. Drift
110
+ # usually means the index predates a model change; queries
111
+ # still run but return degraded or wrongly-scoped results.
112
+ # * `:raise` — fail the query with
113
+ # {Parse::Core::VectorSearchable::IndexDriftError}. Strict mode
114
+ # for deployments that treat drift as a release blocker.
115
+ # * `:ignore` — skip verification entirely.
116
+ #
117
+ # @param value [Symbol]
118
+ # @return [Symbol]
119
+ def index_drift_policy=(value)
120
+ v = value.respond_to?(:to_sym) ? value.to_sym : nil
121
+ unless v && INDEX_DRIFT_POLICIES.include?(v)
122
+ raise ArgumentError,
123
+ "Parse::VectorSearch.index_drift_policy must be one of " \
124
+ "#{INDEX_DRIFT_POLICIES.inspect} (got #{value.inspect})."
125
+ end
126
+ @index_drift_policy = v
127
+ end
128
+
129
+ # @return [Symbol] current drift policy (default `:warn`).
130
+ def index_drift_policy
131
+ @index_drift_policy ||= :warn
132
+ end
99
133
  # Low-level `$vectorSearch` entry point.
100
134
  #
101
135
  # @param collection_name [String] Parse class name / Mongo
@@ -740,7 +740,13 @@ module Parse
740
740
  # callback handling based on the request origin.
741
741
  # @return [Boolean] true if the request originated from Ruby Parse Stack
742
742
  def ruby_initiated?
743
- @ruby_initiated ||= begin
743
+ # Stable memoization: a plain `||=` re-derives whenever the stored value
744
+ # is `false`, so a previously-computed (or externally-stamped, e.g. by
745
+ # Parse::Webhooks.call_route) `false` would be recomputed on every call
746
+ # and could disagree with the stamping caller. Cache on `defined?` so a
747
+ # `false` result is memoized exactly once and never silently re-derived.
748
+ return @ruby_initiated if defined?(@ruby_initiated) && !@ruby_initiated.nil?
749
+ @ruby_initiated = begin
744
750
  request_id = nil
745
751
 
746
752
  if @raw.respond_to?(:[])
@@ -455,34 +455,114 @@ module Parse
455
455
  end
456
456
 
457
457
  if type == :after_save && payload&.parse_object.present? && payload.parse_object.is_a?(Parse::Object)
458
- # Handle after_save callbacks intelligently based on request origin.
459
- # For trusted-Ruby-initiated saves (both `_RB_` header AND master
460
- # key), Parse Stack's local `run_callbacks :save` will fire
461
- # after_create and after_save callbacks after the REST response
462
- # returns; firing them again here would double-fire any side
463
- # effect (e.g. an `after_save :send_email` would send two emails
464
- # per save). For everything else -- client-initiated saves, or a
465
- # spoofed `_RB_` from a non-master client -- Parse Stack never had
466
- # a chance to run callbacks, so we fire them here.
458
+ # The chained ActiveModel after_save/after_create callbacks are NOT
459
+ # fired here. `call!` dispatches every trigger twice -- once for the
460
+ # specific class route and once for the generic `"*"` route -- so
461
+ # firing the model callbacks inside this per-route block double-fired
462
+ # them for any app that registered BOTH a class route and a `"*"`
463
+ # route (e.g. an `after_save :send_email` would send two emails per
464
+ # save). The dispatch now lives in `run_after_save_chain`, which
465
+ # `call!` invokes exactly once per delivery after both route calls.
467
466
  #
468
- # The decision depends ONLY on request origin, never on what the
469
- # handler returned. Parse Server discards the afterSave response
470
- # body entirely (it resolves {success} even if the handler throws),
471
- # so a handler that returns the parse_object -- the recommended
472
- # before_save pattern, easy to copy by mistake -- must NOT silently
473
- # suppress these callbacks. We normalize the result to `true` below
474
- # so a returned object never leaks into the response or the log.
475
- is_new = payload.original.nil?
476
- unless trusted_ruby_initiated
477
- payload.parse_object.run_after_create_callbacks if is_new
478
- payload.parse_object.run_after_save_callbacks
479
- end
467
+ # We still normalize the result to `true` so a handler that returned
468
+ # the parse_object (the recommended before_save pattern, easy to copy
469
+ # by mistake) never leaks an object into the response or the log.
480
470
  result = true
481
471
  end
482
472
 
483
473
  result
484
474
  end
485
475
 
476
+ # Fires the chained ActiveModel after_save (and after_create, for a new
477
+ # object) callbacks for an afterSave delivery -- exactly once per request.
478
+ #
479
+ # This lives in `call!` rather than `call_route` because `call!` dispatches
480
+ # every trigger twice (the specific class route AND the generic `"*"`
481
+ # route). Firing the model callbacks per-route would double-fire any side
482
+ # effect for an app that registered both routes. Calling this once, after
483
+ # both route calls, fires the chain exactly once regardless of how many
484
+ # routes matched.
485
+ #
486
+ # The decision to fire depends ONLY on request origin, never on what a
487
+ # handler returned: Parse Server discards the afterSave response body
488
+ # entirely, so a handler returning the parse_object must not suppress the
489
+ # callbacks. For trusted-Ruby-initiated saves (both the `_RB_` request-id
490
+ # header AND the master key) Parse Stack's local `run_callbacks :save`
491
+ # already fires these after the REST response returns, so we skip them
492
+ # here to avoid the double-fire. The route-present guard preserves the
493
+ # "an unregistered afterSave trigger never fires model callbacks" contract
494
+ # that `call_route`'s early return used to provide.
495
+ #
496
+ # @param payload [Parse::Webhooks::Payload] the afterSave payload.
497
+ # @return [void]
498
+ def run_after_save_chain(payload)
499
+ return unless payload&.after_save?
500
+ return unless payload.parse_object.is_a?(Parse::Object)
501
+
502
+ # Preserve the "no registered route => no model callbacks" behavior that
503
+ # call_route's `return unless routes[type][className].present?` enforced.
504
+ # Mirror that guard exactly: key on parse_class.to_s (as call_route does)
505
+ # and use `.present?` on the value -- registration stores an Array, and an
506
+ # empty/absent registration must NOT fire (matching the original).
507
+ after_save_routes = routes[:after_save]
508
+ return unless after_save_routes &&
509
+ (after_save_routes[payload.parse_class.to_s].present? ||
510
+ after_save_routes["*"].present?)
511
+
512
+ # Trusted-Ruby-initiated saves run their callbacks locally; firing again
513
+ # here would double them. This must match call_route's trusted_ruby_initiated
514
+ # EXACTLY. call_route runs (and stamps @ruby_initiated) before this for any
515
+ # matched route, so read that stamped value rather than recomputing via
516
+ # `ruby_initiated?` -- whose `||=` memoization re-derives on a stamped
517
+ # `false` and could disagree with call_route's header lookup.
518
+ return if payload.ruby_initiated? && payload.master? == true
519
+
520
+ # By the time afterSave fires the object is ALREADY persisted in Parse
521
+ # Server, and Parse Server discards the afterSave response body entirely
522
+ # (it resolves success even if the handler throws). So a chained callback
523
+ # that raises must not (a) 500 the webhook endpoint -- `call!`'s rescue
524
+ # only catches ResponseError / ValidationError, so a bare StandardError
525
+ # would escape -- nor (b) take out the OTHER phase's unrelated side
526
+ # effects. Run the after_create and after_save phases independently, each
527
+ # guarded, logging and swallowing any StandardError. This mirrors Parse's
528
+ # own afterSave semantics (log-and-continue on a post-persist failure):
529
+ # a raising `after_create :send_welcome_email` no longer silently skips
530
+ # an unrelated `after_save :reindex`, and neither can crash the endpoint.
531
+ obj = payload.parse_object
532
+ run_after_save_phase(obj, :after_create) if payload.original.nil?
533
+ run_after_save_phase(obj, :after_save)
534
+ nil
535
+ end
536
+
537
+ # Runs one phase (:after_create or :after_save) of an afterSave object's
538
+ # chained ActiveModel callbacks, swallowing and logging any StandardError
539
+ # so a post-persist callback failure can't crash the webhook endpoint or
540
+ # suppress the sibling phase. ActiveModel still halts the rest of *this*
541
+ # phase's chain on a raise -- only the cross-phase / endpoint blast radius
542
+ # is contained here. Note this also swallows a ResponseError/ValidationError
543
+ # raised from inside an after_save callback: afterSave is post-persist and
544
+ # Parse Server discards the response body, so an `error!` there cannot deny
545
+ # the (already-committed) write -- it is logged, not propagated.
546
+ # @param obj [Parse::Object] the persisted afterSave object.
547
+ # @param phase [Symbol] :after_create or :after_save.
548
+ # @return [void]
549
+ def run_after_save_phase(obj, phase)
550
+ case phase
551
+ when :after_create then obj.run_after_create_callbacks
552
+ when :after_save then obj.run_after_save_callbacks
553
+ end
554
+ nil
555
+ rescue => e
556
+ # Redact the exception message before logging: a callback error can echo
557
+ # record contents/tokens, and the rest of this file routes log output
558
+ # through the same redactor.
559
+ warn "[Parse::Webhooks] afterSave #{phase} callback raised for " \
560
+ "#{obj.class}##{obj.id} -- the object is already persisted; " \
561
+ "logging and continuing: #{e.class}: " \
562
+ "#{Parse::Middleware::BodyBuilder.redact(e.message)}"
563
+ nil
564
+ end
565
+
486
566
  # Generates a success response for Parse Server.
487
567
  # @param data [Object] the data to send back with the success.
488
568
  # @return [Hash] a success data payload
@@ -688,6 +768,12 @@ module Parse
688
768
  # call hooks subscribed to any class route
689
769
  generic_result = Parse::Webhooks.call_route(payload.trigger_name, "*", payload)
690
770
  result = generic_result if generic_result.present? && result.nil?
771
+
772
+ # Fire the chained ActiveModel after_save/after_create callbacks
773
+ # exactly once per delivery -- after BOTH route calls above -- so an
774
+ # app that registers both a class route and a `"*"` route doesn't
775
+ # double-fire them. No-op for every non-afterSave trigger.
776
+ Parse::Webhooks.run_after_save_chain(payload)
691
777
  else
692
778
  if self.logging.present?
693
779
  puts "[Webhooks] --> Could not find mapping route for " \
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.1
4
+ version: 5.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -319,8 +319,11 @@ files:
319
319
  - lib/parse/clp_scope.rb
320
320
  - lib/parse/console.rb
321
321
  - lib/parse/embeddings.rb
322
+ - lib/parse/embeddings/batch_embedder.rb
323
+ - lib/parse/embeddings/cache.rb
322
324
  - lib/parse/embeddings/cohere.rb
323
325
  - lib/parse/embeddings/fixture.rb
326
+ - lib/parse/embeddings/image_fetch.rb
324
327
  - lib/parse/embeddings/jina.rb
325
328
  - lib/parse/embeddings/local_http.rb
326
329
  - lib/parse/embeddings/openai.rb