parse-stack-next 5.3.0 → 5.4.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +461 -0
  4. data/Gemfile +7 -0
  5. data/Gemfile.lock +12 -4
  6. data/README.md +160 -3
  7. data/Rakefile +52 -3
  8. data/docs/atlas_vector_search_guide.md +86 -2
  9. data/docs/client_sdk_guide.md +5 -0
  10. data/docs/mcp_guide.md +59 -4
  11. data/docs/mongodb_direct_guide.md +93 -1
  12. data/docs/usage_guide.md +11 -1
  13. data/docs/webhooks_guide.md +418 -0
  14. data/examples/README.md +46 -0
  15. data/examples/basic_client.rb +93 -0
  16. data/examples/basic_server.rb +109 -0
  17. data/examples/live_query_listener.rb +98 -0
  18. data/examples/rag_chatbot.rb +221 -0
  19. data/examples/webhook_server.rb +111 -0
  20. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  21. data/lib/parse/agent/tools.rb +45 -5
  22. data/lib/parse/api/aggregate.rb +7 -1
  23. data/lib/parse/api/cloud_functions.rb +12 -4
  24. data/lib/parse/api/hooks.rb +46 -9
  25. data/lib/parse/api/objects.rb +16 -2
  26. data/lib/parse/api/path_segment.rb +33 -0
  27. data/lib/parse/api/server.rb +94 -0
  28. data/lib/parse/api/users.rb +58 -2
  29. data/lib/parse/atlas_search.rb +7 -7
  30. data/lib/parse/client/body_builder.rb +5 -0
  31. data/lib/parse/client/protocol.rb +4 -0
  32. data/lib/parse/client.rb +55 -2
  33. data/lib/parse/embeddings/spend_cap.rb +255 -0
  34. data/lib/parse/embeddings.rb +1 -0
  35. data/lib/parse/live_query/client.rb +3 -1
  36. data/lib/parse/live_query/subscription.rb +32 -5
  37. data/lib/parse/model/acl.rb +4 -2
  38. data/lib/parse/model/classes/audience.rb +52 -4
  39. data/lib/parse/model/classes/user.rb +180 -3
  40. data/lib/parse/model/core/embed_managed.rb +113 -0
  41. data/lib/parse/model/core/querying.rb +3 -1
  42. data/lib/parse/model/core/vector_searchable.rb +161 -0
  43. data/lib/parse/model/object.rb +28 -5
  44. data/lib/parse/mongodb.rb +7 -1
  45. data/lib/parse/pipeline_security.rb +5 -3
  46. data/lib/parse/query/constraints.rb +29 -0
  47. data/lib/parse/query.rb +265 -27
  48. data/lib/parse/retrieval/agent_tool.rb +49 -0
  49. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  50. data/lib/parse/retrieval/reranker.rb +157 -0
  51. data/lib/parse/retrieval/retriever.rb +110 -23
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +17 -0
  54. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  55. data/lib/parse/vector_search/hybrid.rb +578 -0
  56. data/lib/parse/webhooks/payload.rb +252 -7
  57. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  58. data/lib/parse/webhooks.rb +215 -3
  59. data/scripts/docker/Dockerfile.parse +5 -1
  60. data/scripts/docker/docker-compose.test.yml +31 -0
  61. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  62. data/scripts/docker/preflight.sh +76 -0
  63. data/scripts/start-parse.sh +52 -4
  64. metadata +15 -1
data/lib/parse/query.rb CHANGED
@@ -370,6 +370,19 @@ module Parse
370
370
  # @param where [Array] an array of {Parse::Constraint} objects.
371
371
  # @return [Hash] a hash representing the compiled query, with
372
372
  # internal routing markers stripped.
373
+ # One-shot process latch so {#warn_if_public_explain_restricted!} emits
374
+ # the allowPublicExplain guidance at most once per process rather than on
375
+ # every explain call.
376
+ # @!visibility private
377
+ def public_explain_warned?
378
+ @public_explain_warned == true
379
+ end
380
+
381
+ # @!visibility private
382
+ def public_explain_warned!
383
+ @public_explain_warned = true
384
+ end
385
+
373
386
  def compile_where(where)
374
387
  constraint_reduce(where).reject { |k, _| k.is_a?(String) && k.start_with?("__") }
375
388
  end
@@ -480,6 +493,7 @@ module Parse
480
493
  @where = []
481
494
  @order = []
482
495
  @keys = []
496
+ @exclude_keys = []
483
497
  @includes = []
484
498
  @limit = nil
485
499
  @skip = 0
@@ -495,6 +509,7 @@ module Parse
495
509
  # unless the caller said otherwise.
496
510
  @use_master_key = nil
497
511
  @verbose_aggregate = false
512
+ @hint = nil
498
513
  conditions constraints
499
514
  end # initialize
500
515
 
@@ -616,6 +631,50 @@ module Parse
616
631
 
617
632
  alias_method :select_fields, :keys
618
633
 
634
+ # Set a server-side field denylist for this query.
635
+ # When set, Parse Server excludes the named fields from each returned
636
+ # object, complementing the {#keys} allowlist. The two options can be
637
+ # combined: Parse Server first applies the {#keys} allowlist, then
638
+ # strips any field names listed in +excludeKeys+.
639
+ #
640
+ # @note On the REST query path (+encode: true+ in {#compile}) this maps to
641
+ # Parse Server's path-scoped +excludeKeys+. On the mongo-direct path
642
+ # (explicit +.results_direct+, an auto-route, or an aggregation that
643
+ # auto-promotes — e.g. an +$inQuery+ pointer constraint that rewrites to
644
+ # a +$lookup+) the pipeline can only project the {#keys} allowlist, so
645
+ # the SDK honors the denylist as a post-fetch sanitize over the returned
646
+ # results instead. That mongo-direct sanitize is recursive by name: it
647
+ # strips EVERY key with a matching name at any depth, so excluding a
648
+ # field also removes a same-named field inside included/nested objects —
649
+ # broader than the REST path's top-level/dotted scoping. Reserved
650
+ # envelope fields (+objectId+, +className+, +__type+, +createdAt+,
651
+ # +updatedAt+, +ACL+ and their Mongo storage-form names) are never
652
+ # stripped, so object reconstruction is unaffected. The raw aggregation
653
+ # accessor (`aggregate(...).raw`) returns unredacted documents — the
654
+ # sanitize applies to the object/decoded result paths. +excludeKeys+ is a
655
+ # projection convenience, not an ACL/CLP boundary, so it does not affect
656
+ # access control.
657
+ #
658
+ # @example Omit a single sensitive field
659
+ # Post.query.exclude_keys(:secret_token).results
660
+ #
661
+ # @example Omit multiple fields
662
+ # Post.query.exclude_keys(:secret_token, :internal_notes).results
663
+ #
664
+ # @param fields [Array<Symbol, String>] the field names to exclude.
665
+ # @return [self]
666
+ def exclude_keys(*fields)
667
+ @exclude_keys ||= []
668
+ fields.flatten.each do |field|
669
+ if field.nil? == false && field.respond_to?(:to_s)
670
+ @exclude_keys.push Query.format_field(field).to_sym
671
+ end
672
+ end
673
+ @exclude_keys.uniq!
674
+ @results = nil if fields.count > 0
675
+ self # chaining
676
+ end
677
+
619
678
  # Extract values for a specific field from all matching objects.
620
679
  # This is similar to keys() but returns an array of the actual field values
621
680
  # instead of objects with only those fields selected.
@@ -792,6 +851,31 @@ module Parse
792
851
  self
793
852
  end
794
853
 
854
+ # Set a MongoDB index hint for this query.
855
+ # Forces Parse Server (and the underlying MongoDB driver) to use the
856
+ # named index instead of the query planner's choice. Useful for
857
+ # benchmarking or for working around sub-optimal plan selection.
858
+ # The hint is emitted in the compiled REST query body as the +hint+
859
+ # parameter (supported by Parse Server 7.4.0+) AND forwarded to the
860
+ # mongo-direct path — +results_direct+ / +count_direct+ / +distinct_direct+
861
+ # pass it to {Parse::MongoDB.aggregate}/{Parse::MongoDB.find} as the Mongo
862
+ # +hint+ option, so a plan diagnosed with {#explain} can be corrected on
863
+ # either path.
864
+ #
865
+ # @example Force a specific index
866
+ # Post.query(:status => "published").hint("status_1_created_at_-1").results
867
+ #
868
+ # @param index_name [String, nil, :_read_] the index name or key pattern to use,
869
+ # or +nil+ to clear a previously set hint. Called with no arguments acts as a
870
+ # reader and returns the current hint value.
871
+ # @return [String, nil, self]
872
+ HINT_UNSET = :_hint_unset_ # @!visibility private
873
+ def hint(index_name = HINT_UNSET)
874
+ return @hint if index_name.equal?(HINT_UNSET)
875
+ @hint = index_name
876
+ self
877
+ end
878
+
795
879
  def related_to(field, pointer)
796
880
  raise ArgumentError, "Object value must be a Parse::Pointer type" unless pointer.is_a?(Parse::Pointer)
797
881
  add_constraint field.to_sym.related_to, pointer
@@ -1462,24 +1546,125 @@ module Parse
1462
1546
  # Build headers for the query request
1463
1547
  def _headers
1464
1548
  headers = {}
1465
- if read_preference.present?
1466
- pref = read_preference.to_s.upcase.gsub("_", " ").split.join("_")
1467
- # Normalize common formats
1468
- pref = case pref
1469
- when "PRIMARY" then "PRIMARY"
1470
- when "PRIMARY_PREFERRED", "PRIMARYPREFERRED" then "PRIMARY_PREFERRED"
1471
- when "SECONDARY" then "SECONDARY"
1472
- when "SECONDARY_PREFERRED", "SECONDARYPREFERRED" then "SECONDARY_PREFERRED"
1473
- when "NEAREST" then "NEAREST"
1474
- else pref
1475
- end
1476
- if Parse::Protocol::READ_PREFERENCES.include?(pref)
1477
- headers[Parse::Protocol::READ_PREFERENCE] = pref
1478
- else
1479
- warn "[ParseQuery] Invalid read preference: #{read_preference}. Valid values: #{Parse::Protocol::READ_PREFERENCES.join(", ")}"
1549
+ pref = normalized_read_preference
1550
+ headers[Parse::Protocol::READ_PREFERENCE] = pref if pref
1551
+ headers
1552
+ end
1553
+
1554
+ # Normalize the query's `read_pref` value to the canonical Parse Server
1555
+ # token (`PRIMARY`, `PRIMARY_PREFERRED`, `SECONDARY`, `SECONDARY_PREFERRED`,
1556
+ # `NEAREST`). Parse Server's `_parseReadPreference` upcases the incoming
1557
+ # string and matches exactly these forms, so the SDK emits them verbatim.
1558
+ # @return [String, nil] the canonical token, or nil when no preference is
1559
+ # set. Warns and returns nil on an unrecognized value.
1560
+ # @!visibility private
1561
+ def normalized_read_preference
1562
+ return nil unless read_preference.present?
1563
+ pref = read_preference.to_s.upcase.gsub("_", " ").split.join("_")
1564
+ pref = case pref
1565
+ when "PRIMARY" then "PRIMARY"
1566
+ when "PRIMARY_PREFERRED", "PRIMARYPREFERRED" then "PRIMARY_PREFERRED"
1567
+ when "SECONDARY" then "SECONDARY"
1568
+ when "SECONDARY_PREFERRED", "SECONDARYPREFERRED" then "SECONDARY_PREFERRED"
1569
+ when "NEAREST" then "NEAREST"
1570
+ else pref
1480
1571
  end
1572
+ return pref if Parse::Protocol::READ_PREFERENCES.include?(pref)
1573
+ warn "[ParseQuery] Invalid read preference: #{read_preference}. Valid values: #{Parse::Protocol::READ_PREFERENCES.join(", ")}"
1574
+ nil
1575
+ end
1576
+
1577
+ # Proactive guidance for {#explain} on Parse Server 9.0+. PS 9.0 defaults
1578
+ # `allowPublicExplain` to false, so a NON-master explain is rejected unless
1579
+ # the operator re-enabled it server-side. That flag is not surfaced in
1580
+ # `/serverInfo`, so we cannot know for certain whether the call will be
1581
+ # allowed — we therefore WARN (one-shot) and still run the call:
1582
+ # `allowPublicExplain: true` servers return the plan; restricted servers
1583
+ # fail and {#explain}'s reactive enrichment explains why.
1584
+ #
1585
+ # We warn only when the query is clearly non-master (explicit
1586
+ # `use_master_key: false`, or a session-token scope) AND the server version
1587
+ # is known to restrict it — so a master-default explain (the common case)
1588
+ # and unknown-version servers don't get spurious noise.
1589
+ # @!visibility private
1590
+ def warn_if_public_explain_restricted!
1591
+ non_master = use_master_key == false ||
1592
+ (session_token.present? && use_master_key != true)
1593
+ return unless non_master
1594
+ return unless client.respond_to?(:server_supports?) && client.respond_to?(:server_version)
1595
+ return if client.server_version.to_s.empty? # known version only
1596
+ return if client.server_supports?(:public_explain)
1597
+ return if Parse::Query.public_explain_warned?
1598
+ Parse::Query.public_explain_warned!
1599
+ message = "[ParseQuery:Explain] Parse Server #{client.server_version} defaults " \
1600
+ "`allowPublicExplain` to false; a non-master explain will be rejected " \
1601
+ "unless the server enables it. Run explain with use_master_key: true, or " \
1602
+ "set `allowPublicExplain: true` in the server's databaseOptions."
1603
+ if defined?(Parse) && Parse.respond_to?(:logger) && Parse.logger
1604
+ Parse.logger.warn(message)
1605
+ else
1606
+ warn message
1481
1607
  end
1482
- headers
1608
+ end
1609
+
1610
+ # Honor the `exclude_keys` denylist on the mongo-direct path by redacting
1611
+ # the matching fields from the fetched results in Ruby — the mongo-direct
1612
+ # pipeline projects only the `keys` allowlist (Parse Server's REST
1613
+ # `excludeKeys` has no mongo-direct equivalent), so without this the
1614
+ # denylist would silently have no effect. This is a pure post-fetch
1615
+ # sanitize over the Parse-format result hashes; it does NOT change the
1616
+ # MongoDB query or pipeline.
1617
+ #
1618
+ # Semantics differ from the REST path: `excludeKeys` on REST is
1619
+ # path-scoped (top-level / dotted), whereas this drops EVERY key with a
1620
+ # matching name at ANY depth — so excluding `:name` also strips `name`
1621
+ # from included/nested objects. This matches the "recursively drop all
1622
+ # keys with that name" contract for the mongo-direct path.
1623
+ #
1624
+ # `exclude_keys` is a projection convenience, NOT an ACL/CLP boundary, so
1625
+ # this redaction is about returned-object shape, not access control.
1626
+ #
1627
+ # Decode-critical structural keys are never stripped, so a query can ask
1628
+ # to exclude e.g. `:objectId` without breaking object reconstruction.
1629
+ # @param results [Array<Hash>] Parse-format result hashes (mutated in place)
1630
+ # @return [Array<Hash>] the same array, with excluded keys removed
1631
+ # @!visibility private
1632
+ def redact_excluded_keys!(results)
1633
+ return results unless @exclude_keys&.any?
1634
+ names = @exclude_keys.map(&:to_s) - RESERVED_EXCLUDE_KEYS
1635
+ return results if names.empty?
1636
+ drop = names.to_set
1637
+ results.each { |row| recursively_drop_keys!(row, drop) }
1638
+ results
1639
+ end
1640
+
1641
+ # Reserved fields that {#redact_excluded_keys!} never strips: dropping these
1642
+ # would break {#decode} (objectId / className / __type) or remove the
1643
+ # required Parse envelope. Both the Parse-format names (objectId, createdAt,
1644
+ # updatedAt, ACL) and their Mongo storage-form counterparts (_id,
1645
+ # _created_at, _updated_at, _acl) are guarded, so the redaction is safe even
1646
+ # if it is ever pointed at a raw Mongo document, and a caller can't break
1647
+ # reconstruction by excluding e.g. `:_id`. This is an SDK safety choice, not
1648
+ # an assertion about which fields Parse Server's REST `excludeKeys` strips.
1649
+ RESERVED_EXCLUDE_KEYS = %w[
1650
+ objectId className __type createdAt updatedAt ACL
1651
+ _id _created_at _updated_at _acl
1652
+ ].freeze
1653
+
1654
+ # Recursively delete every key named in +names+ from a nested
1655
+ # Hash/Array structure, in place. Symbol and string keys both match.
1656
+ # @param value [Object] a Hash, Array, or scalar
1657
+ # @param names [Set<String>] the key names to drop
1658
+ # @!visibility private
1659
+ def recursively_drop_keys!(value, names)
1660
+ case value
1661
+ when Hash
1662
+ value.reject! { |k, _| names.include?(k.to_s) }
1663
+ value.each_value { |v| recursively_drop_keys!(v, names) }
1664
+ when Array
1665
+ value.each { |v| recursively_drop_keys!(v, names) }
1666
+ end
1667
+ value
1483
1668
  end
1484
1669
 
1485
1670
  # Performs the fetch request for the query.
@@ -1921,11 +2106,18 @@ module Parse
1921
2106
  master: master,
1922
2107
  acl_user: acl_user,
1923
2108
  acl_role: acl_role,
1924
- read_preference: @read_preference)
2109
+ read_preference: @read_preference,
2110
+ hint: @hint)
1925
2111
 
1926
2112
  # Convert MongoDB documents to Parse format
1927
2113
  parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, @table)
1928
2114
 
2115
+ # Honor exclude_keys on the mongo-direct path: the pipeline can only
2116
+ # project the keys allowlist, so apply the denylist here as a post-fetch
2117
+ # sanitize over the Parse-format hashes (before the raw/decode fork so
2118
+ # both shapes are redacted). Does not alter the MongoDB query.
2119
+ redact_excluded_keys!(parse_results)
2120
+
1929
2121
  if raw
1930
2122
  return parse_results.each(&block) if block_given?
1931
2123
  return parse_results
@@ -2042,7 +2234,8 @@ module Parse
2042
2234
  master: master,
2043
2235
  acl_user: acl_user,
2044
2236
  acl_role: acl_role,
2045
- read_preference: @read_preference)
2237
+ read_preference: @read_preference,
2238
+ hint: @hint)
2046
2239
 
2047
2240
  # Extract count from result
2048
2241
  return 0 if raw_results.empty?
@@ -2121,6 +2314,7 @@ module Parse
2121
2314
  raw_results = Parse::MongoDB.aggregate(@table, pipeline,
2122
2315
  allow_internal_fields: true,
2123
2316
  read_preference: @read_preference,
2317
+ hint: @hint,
2124
2318
  session_token: session_token,
2125
2319
  master: master,
2126
2320
  acl_user: acl_user,
@@ -2239,7 +2433,8 @@ module Parse
2239
2433
  # SDK-built pipeline only — see results_direct for rationale.
2240
2434
  raw_results = Parse::MongoDB.aggregate(@table, pipeline,
2241
2435
  allow_internal_fields: true,
2242
- read_preference: @read_preference)
2436
+ read_preference: @read_preference,
2437
+ hint: @hint)
2243
2438
 
2244
2439
  # Convert results
2245
2440
  if options[:raw]
@@ -3032,7 +3227,7 @@ module Parse
3032
3227
  # events can arrive. Optional.
3033
3228
  # @return [Parse::LiveQuery::Subscription] the subscription object
3034
3229
  # @see Parse::LiveQuery::Subscription
3035
- def subscribe(fields: nil, session_token: nil, client: nil, use_master_key: false, &block)
3230
+ def subscribe(fields: nil, keys: nil, watch: nil, session_token: nil, client: nil, use_master_key: false, &block)
3036
3231
  require_relative "live_query"
3037
3232
 
3038
3233
  lq_client = client || Parse::LiveQuery.client
@@ -3040,6 +3235,8 @@ module Parse
3040
3235
  @table,
3041
3236
  where: compile_where,
3042
3237
  fields: fields,
3238
+ keys: keys,
3239
+ watch: watch,
3043
3240
  session_token: session_token || @session_token,
3044
3241
  use_master_key: use_master_key,
3045
3242
  &block
@@ -3063,11 +3260,22 @@ module Parse
3063
3260
  # @note This feature requires MongoDB explain support in Parse Server.
3064
3261
  # The format of the returned plan depends on the MongoDB version.
3065
3262
  def explain
3263
+ warn_if_public_explain_restricted!
3066
3264
  compiled_query = compile
3067
3265
  compiled_query[:explain] = true
3068
- response = client.find_objects(@table, compiled_query.as_json, **_opts)
3266
+ response = client.find_objects(@table, compiled_query.as_json, headers: _headers, **_opts)
3069
3267
  if response.error?
3070
- puts "[ParseQuery:Explain] #{response.error}"
3268
+ # Parse Server 9.0+ defaults `allowPublicExplain` to false, so a
3269
+ # non-master explain that worked on 8.x now returns a permission
3270
+ # error. Surface that as actionable guidance instead of a bare 403.
3271
+ if response.respond_to?(:permission_denied?) && response.permission_denied?
3272
+ puts "[ParseQuery:Explain] #{response.error} — Parse Server 9.0+ defaults " \
3273
+ "`allowPublicExplain` to false; query explain now requires the master key " \
3274
+ "(use_master_key: true) or `allowPublicExplain: true` in the server's " \
3275
+ "databaseOptions."
3276
+ else
3277
+ puts "[ParseQuery:Explain] #{response.error}"
3278
+ end
3071
3279
  return {}
3072
3280
  end
3073
3281
  response.result
@@ -3131,7 +3339,7 @@ module Parse
3131
3339
  # at the top-level stage.
3132
3340
  BLOCKED_PIPELINE_STAGES = Parse::PipelineSecurity::DENIED_OPERATORS
3133
3341
 
3134
- def aggregate(pipeline, verbose: nil, mongo_direct: nil, rewrite_lookups: nil)
3342
+ def aggregate(pipeline, verbose: nil, mongo_direct: nil, rewrite_lookups: nil, raw_values: false, raw_field_names: false)
3135
3343
  validate_pipeline!(pipeline)
3136
3344
 
3137
3345
  # Auto-rewrite LLM-style $lookup stages against logical Parse class
@@ -3275,7 +3483,8 @@ module Parse
3275
3483
  complete_pipeline = translate_pipeline_for_direct_mongodb(complete_pipeline)
3276
3484
  end
3277
3485
 
3278
- Aggregation.new(self, complete_pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false)
3486
+ Aggregation.new(self, complete_pipeline, verbose: verbose, mongo_direct: use_mongo_direct || false,
3487
+ raw_values: raw_values, raw_field_names: raw_field_names)
3279
3488
  end
3280
3489
 
3281
3490
  # Apply the direct-MongoDB stage converter to every stage in a pipeline.
@@ -3938,6 +4147,7 @@ module Parse
3938
4147
 
3939
4148
  q[:include] = @includes.join(",") unless @includes.empty?
3940
4149
  q[:keys] = @keys.join(",") unless @keys.empty?
4150
+ q[:excludeKeys] = @exclude_keys.join(",") if encode && @exclude_keys&.any?
3941
4151
  q[:order] = @order.join(",") unless @order.empty?
3942
4152
  unless @where.empty?
3943
4153
  q[:where] = Parse::Query.compile_where(@where)
@@ -3949,6 +4159,17 @@ module Parse
3949
4159
  q[:limit] = 0
3950
4160
  q[:count] = 1
3951
4161
  end
4162
+ # Read preference must ride the REST query body (restOptions), NOT a
4163
+ # header: Parse Server's middleware does not map any
4164
+ # `X-Parse-Read-Preference` header into request options, so the
4165
+ # header alone is silently ignored and the read always hits the
4166
+ # primary. `RestQuery` reads `readPreference` from restOptions, so
4167
+ # emitting it here is what actually routes the read. (The header is
4168
+ # still sent for any intermediary that honors it; it is harmless.)
4169
+ if encode && (pref = normalized_read_preference)
4170
+ q[:readPreference] = pref
4171
+ end
4172
+ q[:hint] = @hint if @hint
3952
4173
  if includeClassName
3953
4174
  q[:className] = @table
3954
4175
  end
@@ -5207,7 +5428,7 @@ module Parse
5207
5428
  cloned_query = Parse::Query.new(self.instance_variable_get(:@table))
5208
5429
  # Note: :client is intentionally excluded - it contains non-serializable objects
5209
5430
  # (Redis connections, Faraday connections) and should be obtained lazily
5210
- [:count, :where, :order, :keys, :includes, :limit, :skip, :cache, :use_master_key].each do |param|
5431
+ [:count, :where, :order, :keys, :exclude_keys, :includes, :limit, :skip, :cache, :use_master_key, :hint].each do |param|
5211
5432
  if instance_variable_defined?(:"@#{param}")
5212
5433
  value = instance_variable_get(:"@#{param}")
5213
5434
  if value.is_a?(Array) || value.is_a?(Hash)
@@ -5503,12 +5724,19 @@ module Parse
5503
5724
  # @param mongo_direct [Boolean] if true, uses MongoDB directly bypassing Parse Server (required for $literal)
5504
5725
  # @param max_time_ms [Integer, nil] optional server-side time limit in milliseconds passed to
5505
5726
  # {Parse::MongoDB.aggregate} when mongo_direct is true. Pass +nil+ (the default) for no cap.
5506
- def initialize(query, pipeline, verbose: nil, mongo_direct: false, max_time_ms: nil)
5727
+ # @param raw_values [Boolean] when true, passes +rawValues: true+ to the Parse Server REST
5728
+ # aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5729
+ # @param raw_field_names [Boolean] when true, passes +rawFieldNames: true+ to the Parse Server
5730
+ # REST aggregate endpoint (PS 9.9.0+). Has no effect on the mongo-direct path.
5731
+ def initialize(query, pipeline, verbose: nil, mongo_direct: false, max_time_ms: nil,
5732
+ raw_values: false, raw_field_names: false)
5507
5733
  @query = query
5508
5734
  @pipeline = pipeline
5509
5735
  @cached_response = nil
5510
5736
  @mongo_direct = mongo_direct
5511
5737
  @max_time_ms = max_time_ms
5738
+ @raw_values = raw_values
5739
+ @raw_field_names = raw_field_names
5512
5740
  # Use provided verbose setting, or fall back to query's verbose_aggregate setting
5513
5741
  @verbose = verbose.nil? ? @query.instance_variable_get(:@verbose_aggregate) : verbose
5514
5742
  end
@@ -5532,6 +5760,8 @@ module Parse
5532
5760
  @query.instance_variable_get(:@table),
5533
5761
  @pipeline,
5534
5762
  headers: {},
5763
+ raw_values: @raw_values,
5764
+ raw_field_names: @raw_field_names,
5535
5765
  **@query.send(:_opts),
5536
5766
  )
5537
5767
  end
@@ -5555,7 +5785,11 @@ module Parse
5555
5785
  def execute_direct!(max_time_ms: @max_time_ms)
5556
5786
  table = @query.instance_variable_get(:@table)
5557
5787
  auth_kwargs = @query.send(:mongo_direct_auth_kwargs)
5558
- Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, **auth_kwargs)
5788
+ # Forward the parent query's index hint so `query.hint(...).aggregate(...)`
5789
+ # honors it on the mongo-direct path too (parity with results_direct /
5790
+ # count_direct / distinct_direct).
5791
+ hint = @query.instance_variable_get(:@hint)
5792
+ Parse::MongoDB.aggregate(table, @pipeline, max_time_ms: max_time_ms, hint: hint, **auth_kwargs)
5559
5793
  end
5560
5794
 
5561
5795
  # Returns processed results from the aggregation.
@@ -5607,6 +5841,10 @@ module Parse
5607
5841
  def convert_direct_aggregation_item(raw, table)
5608
5842
  if raw_is_parse_document?(raw)
5609
5843
  parse_doc = Parse::MongoDB.convert_document_to_parse(raw, table)
5844
+ # Honor exclude_keys on this mongo-direct aggregation path (e.g. the
5845
+ # $inQuery -> $lookup rewrite) by redacting the denylisted fields from
5846
+ # the converted document before decode. Mirrors results_direct.
5847
+ @query.send(:redact_excluded_keys!, [parse_doc])
5610
5848
  @query.send(:decode, [parse_doc]).first
5611
5849
  else
5612
5850
  AggregationResult.new(Parse::MongoDB.convert_aggregation_document(raw))
@@ -94,6 +94,14 @@ module Parse
94
94
  # AccessDenied for an un-bound agent on a scoped class).
95
95
  scope = Parse::Agent::Tools.resolve_tenant_scope!(agent, cname)
96
96
 
97
+ # Per-tenant embedding spend cap (§16.10 — agent-tool exposure
98
+ # mitigation). semantic_search embeds attacker-controlled query
99
+ # text on every call; charge the estimated query tokens against
100
+ # the tenant's budget BEFORE embedding. HARD-REFUSES once the
101
+ # tenant is over cap. No-op when no limit is configured or for
102
+ # trusted admin agents.
103
+ charge_spend_cap!(agent, scope, query)
104
+
97
105
  # Non-admin agents get quantized scores (membership-inference
98
106
  # defense); admin agents get full precision. Keyed on the
99
107
  # permission tier, not master-key posture.
@@ -144,6 +152,47 @@ module Parse
144
152
  envelope
145
153
  end
146
154
 
155
+ # @!visibility private
156
+ # Charge the estimated query-embedding token cost against the
157
+ # tenant's spend cap. The tenant key is the resolved tenant-scope
158
+ # value (so each tenant has its own budget); unscoped non-admin
159
+ # calls charge the shared default bucket. Admin agents are trusted
160
+ # and skip the cap entirely (mirrors the score-quantize tier check).
161
+ #
162
+ # A cap hit is surfaced as a structured error rather than the raw
163
+ # {Parse::Embeddings::SpendCap::Exceeded} — otherwise the agent's
164
+ # generic-error rescue would collapse it to an opaque "internal
165
+ # error" and the model couldn't self-correct. Two distinct cases:
166
+ #
167
+ # * Transient (`retry_after` non-nil): the window will roll off
168
+ # enough tokens to admit this charge. Surface as
169
+ # {Parse::Agent::RateLimitExceeded} (wire `error_code:
170
+ # :rate_limited`) carrying the real backoff hint so the model
171
+ # waits and retries.
172
+ # * Permanent (`retry_after` nil): the request alone exceeds the cap
173
+ # (`requested > limit`) and can NEVER fit, no matter how long the
174
+ # caller waits. Mapping that to a RateLimitExceeded would tell the
175
+ # model to back off and retry an unsatisfiable request — and it
176
+ # would also crash, since RateLimitExceeded#initialize calls
177
+ # `retry_after.round`. Surface as {Parse::Agent::ValidationError}
178
+ # so the model shrinks the query (or the operator raises the cap).
179
+ def charge_spend_cap!(agent, scope, query)
180
+ return if agent.permissions == :admin
181
+ tenant_id = scope && (scope[:value] || scope["value"])
182
+ tokens = Parse::Embeddings::SpendCap.estimate_tokens(query)
183
+ Parse::Embeddings::SpendCap.charge!(tenant_id: tenant_id, tokens: tokens)
184
+ rescue Parse::Embeddings::SpendCap::Exceeded => e
185
+ if e.retry_after.nil?
186
+ raise Parse::Agent::ValidationError,
187
+ "semantic_search: query too large for the embedding spend cap " \
188
+ "(#{e.requested} tokens requested, limit #{e.limit}/#{e.window}s). " \
189
+ "Shorten the query or raise the cap."
190
+ end
191
+ raise Parse::Agent::RateLimitExceeded.new(
192
+ retry_after: e.retry_after, limit: e.limit, window: e.window,
193
+ )
194
+ end
195
+
147
196
  # @!visibility private
148
197
  # nil -> DEFAULT_MAX_TOTAL_TOKENS; <=0 -> nil (unlimited); else the int.
149
198
  def resolve_token_budget(max_total_tokens)