parse-stack-next 5.2.1 → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -1
@@ -63,6 +63,54 @@ module Parse
63
63
  # binding it to a provider at the property level.
64
64
  class EmbedderNotConfigured < ArgumentError; end
65
65
 
66
+ # Accepted {#vector_visibility} modes.
67
+ VECTOR_VISIBILITY_MODES = %i[owner_only public].freeze
68
+
69
+ # Class-level default for whether this class's `:vector` properties
70
+ # are included in `as_json` serialization.
71
+ #
72
+ # * `:owner_only` (default) — vectors are OMITTED from `as_json`
73
+ # unless the caller passes `include_vectors: true`. Embeddings are
74
+ # large and leak ML signal; the safe default keeps them off the
75
+ # wire and out of API responses. Row-level read access is still
76
+ # governed by ACL as usual — this controls serialization exposure,
77
+ # not row authorization.
78
+ # * `:public` — vectors are INCLUDED in `as_json` by default (a
79
+ # caller can still suppress per-call with `include_vectors: false`).
80
+ #
81
+ # class Article < Parse::Object
82
+ # vector_visibility :public # expose embeddings in as_json
83
+ # property :embedding, :vector, dimensions: 1536, provider: :openai
84
+ # end
85
+ #
86
+ # Read the effective mode by calling with no argument; it inherits
87
+ # from the superclass when unset on the subclass.
88
+ #
89
+ # @param mode [Symbol, nil] one of {VECTOR_VISIBILITY_MODES}, or nil
90
+ # to read the current effective mode.
91
+ # @return [Symbol] the effective mode (when reading) or the mode set.
92
+ # @raise [ArgumentError] on an unknown mode.
93
+ def vector_visibility(mode = nil)
94
+ if mode.nil?
95
+ return @vector_visibility if defined?(@vector_visibility) && @vector_visibility
96
+ return superclass.vector_visibility if superclass.respond_to?(:vector_visibility)
97
+ return :owner_only
98
+ end
99
+ m = mode.to_sym
100
+ unless VECTOR_VISIBILITY_MODES.include?(m)
101
+ raise ArgumentError,
102
+ "#{self}.vector_visibility: mode must be one of " \
103
+ "#{VECTOR_VISIBILITY_MODES.inspect} (got #{mode.inspect})."
104
+ end
105
+ @vector_visibility = m
106
+ end
107
+
108
+ # @return [Boolean] whether `:vector` fields are serialized into
109
+ # `as_json` by default for this class (true only for `:public`).
110
+ def vectors_public_by_default?
111
+ vector_visibility == :public
112
+ end
113
+
66
114
  # Find documents whose declared `:vector` property is closest to
67
115
  # `vector:` under the Atlas vectorSearch index's similarity
68
116
  # function.
@@ -169,6 +217,97 @@ module Parse
169
217
  build_vector_hits(raw_hits)
170
218
  end
171
219
 
220
+ # Hybrid (lexical + vector) search with reciprocal-rank fusion.
221
+ #
222
+ # Runs a lexical Atlas Search branch and a `$vectorSearch` branch
223
+ # independently, then fuses their ranked results client-side via RRF
224
+ # (or, on Atlas 8.0+, server-side via native `$rankFusion` when
225
+ # detected). Both branches enforce ACL/CLP/protectedFields before
226
+ # fusion — see {Parse::VectorSearch::Hybrid}.
227
+ #
228
+ # @example
229
+ # Song.hybrid_search(
230
+ # text: "love songs about rain",
231
+ # lexical: { index: "song_search", query: "rain love" },
232
+ # vector: { num_candidates: 200 },
233
+ # k: 20,
234
+ # fusion: { k_constant: 60, weights: { lexical: 0.4, vector: 0.6 } },
235
+ # )
236
+ #
237
+ # @param text [String, nil] natural-language query. Embedded (via
238
+ # the resolved `:vector` property's `provider:`) for the vector
239
+ # branch, and used as the lexical query unless `lexical[:query]`
240
+ # overrides it.
241
+ # @param query_vector [Array<Float>, Parse::Vector, nil] pre-computed
242
+ # query embedding (alternative to `text:` for the vector branch).
243
+ # @param lexical [Hash] lexical branch config (`:query`, `:index`,
244
+ # `:fields`, `:filter`, `:fuzzy`). `:query` defaults to `text:`.
245
+ # @param vector [Hash] vector branch config (`:field`, `:index`,
246
+ # `:num_candidates`, `:filter`, `:vector_filter`). `:field`
247
+ # defaults to the class's sole `:vector` property; `:index` is
248
+ # auto-discovered when omitted.
249
+ # @param k [Integer] number of fused hits to return.
250
+ # @param fusion [Hash, nil] `:method` (`:rrf` / `:rrf_client`),
251
+ # `:k_constant`, `:weights` (`{ lexical:, vector: }`).
252
+ # @param raw [Boolean] return fused raw rows instead of built
253
+ # Parse::Object instances.
254
+ # @param scope_opts [Hash] ACL/CLP scope kwargs forwarded to both
255
+ # branches (`session_token:` / `master:` / `acl_user:` /
256
+ # `acl_role:`).
257
+ # @return [Array<Parse::Object>] fused, RRF-ordered; each carries
258
+ # `#hybrid_score` and `#hybrid_ranks` (and `#vector_score` /
259
+ # `#search_score` when the branch contributed). `raw: true`
260
+ # returns the fused Hashes.
261
+ def hybrid_search(text: nil, query_vector: nil, lexical: {}, vector: {},
262
+ k: 20, fusion: nil, raw: false, **scope_opts)
263
+ require_relative "../../vector_search/hybrid"
264
+ lex = (lexical || {}).transform_keys(&:to_sym)
265
+ vec = (vector || {}).transform_keys(&:to_sym)
266
+
267
+ field_sym = resolve_vector_field!(vec[:field])
268
+ declared_dims = vector_properties.dig(field_sym, :dimensions)
269
+
270
+ qv = query_vector || vec[:query_vector]
271
+ qv =
272
+ if qv.nil?
273
+ unless text.is_a?(String) && !text.strip.empty?
274
+ raise ArgumentError,
275
+ "#{self}.hybrid_search: pass `text:` (to embed) or a `query_vector:`."
276
+ end
277
+ embed_query_text!(text, field_sym)
278
+ else
279
+ coerce_query_vector(qv)
280
+ end
281
+ Parse::VectorSearch.validate_query_vector!(qv, dimensions: declared_dims)
282
+
283
+ lexical_query = lex[:query] || text
284
+ unless lexical_query.is_a?(String) && !lexical_query.strip.empty?
285
+ raise ArgumentError,
286
+ "#{self}.hybrid_search: needs a lexical query — pass `text:` or `lexical: { query: }`."
287
+ end
288
+
289
+ vector_index = vec[:index] || resolve_vector_index!(field_sym, nil)
290
+
291
+ fused = Parse::VectorSearch::Hybrid.search(
292
+ parse_class,
293
+ lexical: {
294
+ query: lexical_query, index: lex[:index], fields: lex[:fields],
295
+ filter: lex[:filter], fuzzy: lex[:fuzzy],
296
+ },
297
+ vector: {
298
+ query_vector: qv, field: field_sym, index: vector_index,
299
+ num_candidates: vec[:num_candidates], filter: vec[:filter],
300
+ vector_filter: vec[:vector_filter],
301
+ },
302
+ k: k,
303
+ fusion: fusion,
304
+ **scope_opts,
305
+ )
306
+
307
+ return fused if raw
308
+ build_hybrid_hits(fused)
309
+ end
310
+
172
311
  private
173
312
 
174
313
  def resolve_vector_field!(field)
@@ -280,6 +419,28 @@ module Parse
280
419
  obj
281
420
  end.compact
282
421
  end
422
+
423
+ # Build Parse::Object instances from fused hybrid rows, attaching
424
+ # the fused score / per-branch ranks plus whatever per-branch scores
425
+ # survived the merge (`_vscore`, `_score`).
426
+ def build_hybrid_hits(rows)
427
+ return [] if rows.nil? || rows.empty?
428
+ converted = Parse::MongoDB.convert_documents_to_parse(rows, parse_class)
429
+ converted.each_with_index.map do |doc, idx|
430
+ obj = Parse::Object.build(doc, parse_class)
431
+ next nil unless obj
432
+ src = rows[idx]
433
+ hscore = src["_hybrid_score"] || src[:_hybrid_score]
434
+ hranks = src["_hybrid_ranks"] || src[:_hybrid_ranks]
435
+ vscore = src["_vscore"] || src[:_vscore]
436
+ sscore = src["_score"] || src[:_score]
437
+ obj.instance_variable_set(:@_hybrid_score, hscore) unless hscore.nil?
438
+ obj.instance_variable_set(:@_hybrid_ranks, hranks) unless hranks.nil?
439
+ obj.instance_variable_set(:@_vector_score, vscore) unless vscore.nil?
440
+ obj.instance_variable_set(:@_search_score, sscore) unless sscore.nil?
441
+ obj
442
+ end.compact
443
+ end
283
444
  end
284
445
  end
285
446
  end
@@ -734,10 +734,43 @@ module Parse
734
734
  ATTRIBUTES
735
735
  end
736
736
 
737
- # @return [Boolean] Two files are equal if they have the same url
737
+ # The value used to decide whether two {Parse::File}s refer to the same
738
+ # underlying file -- for equality ({#==}) and, through it, for dirty
739
+ # tracking (the property setter compares files with `==` to decide whether
740
+ # a `:file` field changed).
741
+ #
742
+ # Today this is the bare canonical {#url}: signed-URL query parameters are
743
+ # stripped into `@presigned_url` and `force_ssl` coercion is applied, so two
744
+ # files at the same storage location compare equal regardless of how the URL
745
+ # was signed or whether it was `http`/`https`. The URL is the best identity
746
+ # signal currently available -- Parse Server's S3 files adapter does not
747
+ # surface a content digest (ETag / MD5 / sha256) through `Parse::File`.
748
+ #
749
+ # FUTURE DIRECTION: when a files adapter can expose a content hash, this is
750
+ # the single seam to override so equality keys off file *content* instead of
751
+ # URL -- e.g. a custom `Parse::File` subclass or adapter shim returning the
752
+ # S3 ETag / sha256 here. Overriding this one method updates {#==} (and a
753
+ # future `#eql?`/`#hash` pair, if added) without touching dirty tracking.
754
+ # No content-hash source exists yet, so the URL is authoritative for now.
755
+ # @return [String, nil]
756
+ def content_signature
757
+ url
758
+ end
759
+
760
+ # @return [Boolean] Two files are equal when their {#content_signature}
761
+ # matches. Both sides go through the same reader, so the comparison is
762
+ # symmetric and force_ssl-consistent: the previous `@url == u.url` form
763
+ # compared one side's raw stored URL against the other's normalized
764
+ # reader, so two files at the same location read as unequal whenever
765
+ # {Parse::File.force_ssl} coerced one side from `http://` to `https://`
766
+ # (and `a == b` disagreed with `b == a`). Because the default signature is
767
+ # the bare canonical URL (signed-URL query parameters stripped into
768
+ # `@presigned_url`), a freshly re-signed URL for the same object is equal
769
+ # while a different underlying location is not. See {#content_signature}
770
+ # for the content-hash override seam.
738
771
  def ==(u)
739
772
  return false unless u.is_a?(self.class)
740
- @url == u.url
773
+ content_signature == u.content_signature
741
774
  end
742
775
 
743
776
  # Allows mass assignment from a Parse JSON hash.
@@ -182,6 +182,14 @@ module Parse
182
182
  # @return [Hash, nil] Atlas Search highlights blob.
183
183
  def search_highlights; @_search_highlights; end
184
184
 
185
+ # @return [Float, nil] fused reciprocal-rank-fusion score from
186
+ # `Class.hybrid_search`.
187
+ def hybrid_score; @_hybrid_score; end
188
+
189
+ # @return [Hash, nil] per-branch 1-based ranks from
190
+ # `Class.hybrid_search` (`{ lexical:, vector: }`).
191
+ def hybrid_ranks; @_hybrid_ranks; end
192
+
185
193
  # @return [Model::TYPE_OBJECT]
186
194
  def __type; Parse::Model::TYPE_OBJECT; end
187
195
 
@@ -384,7 +392,12 @@ module Parse
384
392
  end
385
393
 
386
394
  # The set of default ACLs to be applied on newly created instances of this class.
387
- # By default, public read and write are enabled unless {default_acl_private} is true.
395
+ # The result follows the class's {acl_policy_setting}: the shipped default
396
+ # policy is `:owner_else_private`, whose fallback half is {Parse::ACL.private}
397
+ # (an empty ACL — readable only by the master key until an owner is resolved
398
+ # at save time). Classes that opt into a `:public*` policy, or set
399
+ # {default_acl_private} / {set_default_acl}, get the corresponding permissions
400
+ # instead.
388
401
  # @see Parse::ACL.everyone
389
402
  # @see Parse::ACL.private
390
403
  # @return [Parse::ACL] the current default ACLs for this class.
@@ -398,8 +411,10 @@ module Parse
398
411
  end
399
412
 
400
413
  # A method to set default ACLs to be applied for newly created
401
- # instances of this class. All subclasses have public read and write enabled
402
- # by default.
414
+ # instances of this class. Unless overridden, subclasses inherit the
415
+ # shipped `:owner_else_private` policy (records are private/master-only
416
+ # until an owner is resolved at save time); use this method (or
417
+ # {acl_policy}) to grant broader access.
403
418
  # @example
404
419
  # class AdminData < Parse::Object
405
420
  #
@@ -1223,8 +1238,16 @@ module Parse
1223
1238
  # signal to clients, and they round-trip through the dedicated
1224
1239
  # embed/find_similar pipelines rather than the standard REST
1225
1240
  # save/find. Pass `include_vectors: true` to opt back in (e.g.,
1226
- # for tests or internal mongo-direct bulk writes).
1227
- unless opts[:include_vectors] == true
1241
+ # for tests or internal mongo-direct bulk writes). A class may flip
1242
+ # the per-class default with `vector_visibility :public`; an explicit
1243
+ # `include_vectors:` in the call always wins over the class default.
1244
+ include_vectors =
1245
+ if opts.key?(:include_vectors)
1246
+ opts[:include_vectors] == true
1247
+ else
1248
+ self.class.respond_to?(:vectors_public_by_default?) && self.class.vectors_public_by_default?
1249
+ end
1250
+ unless include_vectors
1228
1251
  vector_fields = self.class.respond_to?(:fields) ? self.class.fields(:vector).keys.map(&:to_s) : []
1229
1252
  if vector_fields.any?
1230
1253
  except = Array(opts[:except]).map(&:to_s) | vector_fields
data/lib/parse/mongodb.rb CHANGED
@@ -1499,7 +1499,7 @@ module Parse
1499
1499
  # @raise [Parse::ACLScope::ACLRequired] when neither
1500
1500
  # `session_token:` nor `master: true` is supplied and
1501
1501
  # {Parse::ACLScope.require_session_token} is enabled.
1502
- def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil)
1502
+ def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil, hint: nil)
1503
1503
  # AS::N envelope. Payload is intentionally metadata-only —
1504
1504
  # `stage_count`, `stage_types`, `collection`, `scope`,
1505
1505
  # `result_count`, `max_time_ms`, `read_preference`. Pipeline
@@ -1620,6 +1620,11 @@ module Parse
1620
1620
 
1621
1621
  agg_opts = {}
1622
1622
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
1623
+ # Forced index hint (Query#hint). Mirrors Parse Server's REST `hint`
1624
+ # on the mongo-direct path so a bad plan diagnosed with `explain` can
1625
+ # be corrected here too. Accepts an index name (String) or a key
1626
+ # pattern (Hash).
1627
+ agg_opts[:hint] = hint unless hint.nil?
1623
1628
  coll = collection(collection_name)
1624
1629
  if (mode = normalize_read_preference(read_preference))
1625
1630
  coll = coll.with(read: { mode: mode })
@@ -1838,6 +1843,7 @@ module Parse
1838
1843
  cursor = cursor.skip(options[:skip]) if options[:skip]
1839
1844
  cursor = cursor.sort(options[:sort]) if options[:sort]
1840
1845
  cursor = cursor.projection(options[:projection]) if options[:projection]
1846
+ cursor = cursor.hint(options[:hint]) unless options[:hint].nil?
1841
1847
  cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
1842
1848
  results = cursor.to_a
1843
1849
 
@@ -182,13 +182,15 @@ module Parse
182
182
  # that `Parse::AtlasSearch` calls do not break. `$vectorSearch` is
183
183
  # included for `Parse::VectorSearch` — like `$search`, it is a
184
184
  # read-only Atlas index stage and must be the FIRST stage of the
185
- # pipeline (Atlas refuses it otherwise).
185
+ # pipeline (Atlas refuses it otherwise). `$rankFusion` (Atlas 8.0+)
186
+ # is the native server-side reciprocal-rank-fusion stage used by
187
+ # `Parse::VectorSearch::Hybrid` — also a read-only stage-0 operator.
186
188
  ALLOWED_STAGES = %w[
187
189
  $match $group $sort $project $limit $skip $unwind $lookup
188
190
  $count $addFields $set $unset $bucket $bucketAuto $facet
189
191
  $sample $sortByCount $replaceRoot $replaceWith $redact
190
192
  $graphLookup $unionWith
191
- $search $searchMeta $listSearchIndexes $vectorSearch
193
+ $search $searchMeta $listSearchIndexes $vectorSearch $rankFusion
192
194
  ].freeze
193
195
 
194
196
  # Atlas operators that are valid only as the FIRST stage of a
@@ -202,7 +204,7 @@ module Parse
202
204
  # for full-text and vector search is the dedicated
203
205
  # `atlas_search` / `semantic_search` tools, not raw aggregate.
204
206
  STAGE0_ONLY_ATLAS_STAGES = %w[
205
- $search $searchMeta $vectorSearch $listSearchIndexes
207
+ $search $searchMeta $vectorSearch $listSearchIndexes $rankFusion
206
208
  ].freeze
207
209
 
208
210
  # Cap on the length of a caller-supplied `$regex` (or the `regex:`
@@ -499,6 +499,35 @@ module Parse
499
499
  end
500
500
  end
501
501
 
502
+ # Equivalent to the $containedBy Parse query operation. Matches documents
503
+ # where the array field's values are all within the supplied set (the
504
+ # inverse of {ContainsAllConstraint}: the field must be a *subset* of the
505
+ # provided array). The field column should be of type {Array} in your
506
+ # Parse class.
507
+ #
508
+ # q.where :field.contained_by => [1, 2, 3]
509
+ # q.where :tags.contained_by => ["ruby", "rails", "parse"]
510
+ #
511
+ # @see ContainsAllConstraint
512
+ # @see ContainedInConstraint
513
+ class ContainedByConstraint < Constraint
514
+ # @!method contained_by
515
+ # A registered method on a symbol to create the constraint.
516
+ # Maps to Parse operator "$containedBy".
517
+ # @example
518
+ # q.where :tags.contained_by => ["ruby", "rails"]
519
+ # @return [ContainedByConstraint]
520
+ constraint_keyword :$containedBy
521
+ register :contained_by
522
+
523
+ # @return [Hash] the compiled constraint.
524
+ def build
525
+ val = formatted_value
526
+ val = [val].compact unless val.is_a?(Array)
527
+ { @operation.operand => { key => val } }
528
+ end
529
+ end
530
+
502
531
  # Array size constraint using MongoDB aggregation.
503
532
  # Parse Server does not natively support $size query constraint, so we use
504
533
  # MongoDB aggregation pipeline with $expr and $size to check array length.