parse-stack-next 5.7.6 → 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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -0,0 +1,237 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module VectorSearch
6
+ # Derives an Atlas `vectorSearch` index definition from a model's
7
+ # declarations, so the index an operator deploys matches what the SDK
8
+ # will query instead of being written by hand.
9
+ #
10
+ # Sources, all read from the model:
11
+ #
12
+ # * the `:vector` property: path, `numDimensions` (`dimensions:`),
13
+ # `similarity` (`similarity:`, default `cosine`), and the optional
14
+ # index-side `quantization:` (`:scalar` / `:binary`);
15
+ # * `agent_searchable filter_fields:`, the fields the agent tool lets a
16
+ # caller pass as `vector_filter:` (pointer fields map to their
17
+ # `_p_<column>` storage path, matching the pointer translation
18
+ # {Parse::Retrieval.retrieve} applies);
19
+ # * the `agent_tenant_scope` field, which retrieval folds into
20
+ # `$vectorSearch.filter` on every scoped query.
21
+ #
22
+ # Output is deterministic: the vector entry first, then filter entries
23
+ # sorted by path, keys in a fixed order. Generating is side-effect free;
24
+ # applying an index stays explicit through
25
+ # {Parse::Schema::SearchIndexMigrator} (declare it with the
26
+ # `vector_search_index` model macro and run `apply_search_indexes!`).
27
+ #
28
+ # @example
29
+ # class Article < Parse::Object
30
+ # property :embedding, :vector, dimensions: 1024, similarity: :dotProduct,
31
+ # quantization: :scalar
32
+ # agent_searchable field: :embedding, filter_fields: %i[category]
33
+ # vector_search_index "article_vec" # generated; applied explicitly
34
+ # end
35
+ #
36
+ # Parse::VectorSearch::IndexDefinition.build(Article)
37
+ # # => { "fields" => [
38
+ # # { "type" => "vector", "path" => "embedding", "numDimensions" => 1024,
39
+ # # "similarity" => "dotProduct", "quantization" => "scalar" },
40
+ # # { "type" => "filter", "path" => "category" } ] }
41
+ module IndexDefinition
42
+ # Similarity emitted when the property declares none. Atlas requires
43
+ # one; cosine is the safe choice for unit-normalized embeddings.
44
+ DEFAULT_SIMILARITY = "cosine"
45
+
46
+ # Index-side quantization values accepted on a `:vector` property.
47
+ QUANTIZATIONS = %w[scalar binary].freeze
48
+
49
+ # Key order for each field entry, so serialized output is stable.
50
+ VECTOR_KEY_ORDER = %w[type path numDimensions similarity quantization].freeze
51
+
52
+ module_function
53
+
54
+ # Build the definition for one `:vector` field of `model_class`.
55
+ #
56
+ # @param model_class [Class] a Parse::Object subclass.
57
+ # @param field [Symbol, String, nil] the `:vector` property; may be
58
+ # omitted when the class declares exactly one searchable vector.
59
+ # @return [Hash] a string-keyed `{ "fields" => [...] }` definition.
60
+ # @raise [ArgumentError] when the field cannot be resolved.
61
+ def build(model_class, field: nil)
62
+ field_sym = resolve_field!(model_class, field)
63
+ meta = model_class.vector_properties.fetch(field_sym)
64
+
65
+ vector = {
66
+ "type" => "vector",
67
+ # The stored column (field_map), which is what $vectorSearch
68
+ # queries: `body_embedding` is saved as `bodyEmbedding`.
69
+ "path" => (model_class.respond_to?(:vector_storage_path) ? model_class.vector_storage_path(field_sym) : field_sym.to_s),
70
+ "numDimensions" => meta[:dimensions],
71
+ "similarity" => (meta[:similarity] || DEFAULT_SIMILARITY).to_s,
72
+ }
73
+ vector["quantization"] = meta[:quantization].to_s if meta[:quantization]
74
+
75
+ filters = filter_paths(model_class).map { |path| { "type" => "filter", "path" => path } }
76
+ { "fields" => [order_keys(vector)] + filters }
77
+ end
78
+
79
+ # Preview the declaration a `vector_search_index` macro would apply.
80
+ #
81
+ # @return [Hash] `{ name:, type: "vectorSearch", definition: }`.
82
+ def preview(model_class, name:, field: nil)
83
+ { name: name.to_s, type: "vectorSearch", definition: build(model_class, field: field) }
84
+ end
85
+
86
+ # Structured, deterministic comparison of a declared definition with a
87
+ # live one. Accepts either definitions or index documents carrying
88
+ # `latestDefinition` (the shape `$listSearchIndexes` returns).
89
+ #
90
+ # @param declared [Hash] the generated (or declared) definition.
91
+ # @param live [Hash, nil] the live definition or index document.
92
+ # @return [Hash] `{ in_sync:, vector: { key => { declared:, live: } },
93
+ # filters_missing: [...], filters_extra: [...] }`. `vector` lists
94
+ # only differing keys; `filters_missing` are declared filter paths
95
+ # absent from the live index (scoped queries on them fail
96
+ # Atlas-side), `filters_extra` are live paths not declared.
97
+ def diff(declared, live)
98
+ declared_defn = definition_of(declared)
99
+ live_defn = definition_of(live)
100
+
101
+ d_vec = vector_entry(declared_defn)
102
+ # Compare against the live vector entry for the SAME path, so an
103
+ # index with several vector fields does not report false drift.
104
+ l_vec = vector_entry(live_defn, path: d_vec["path"])
105
+ vector_changes = {}
106
+ (VECTOR_KEY_ORDER - %w[type]).each do |key|
107
+ dv = normalize_vector_value(key, d_vec[key])
108
+ lv = normalize_vector_value(key, l_vec[key])
109
+ vector_changes[key] = { declared: dv, live: lv } unless dv == lv
110
+ end
111
+
112
+ d_filters = filter_paths_of(declared_defn)
113
+ l_filters = filter_paths_of(live_defn)
114
+ missing = (d_filters - l_filters).sort
115
+ extra = (l_filters - d_filters).sort
116
+
117
+ {
118
+ in_sync: vector_changes.empty? && missing.empty? && extra.empty?,
119
+ vector: vector_changes,
120
+ filters_missing: missing,
121
+ filters_extra: extra,
122
+ }
123
+ end
124
+
125
+ # @!visibility private
126
+ def resolve_field!(model_class, field)
127
+ unless model_class.respond_to?(:vector_properties)
128
+ raise ArgumentError, "#{model_class.inspect} declares no :vector properties."
129
+ end
130
+ props = model_class.vector_properties
131
+ searchable = props.keys.reject { |k| props[k][:searchable] == false }
132
+ if field
133
+ sym = field.to_sym
134
+ unless searchable.include?(sym)
135
+ raise ArgumentError,
136
+ "#{model_class}: :#{sym} is not a searchable :vector property " \
137
+ "(searchable: #{searchable.inspect})."
138
+ end
139
+ return sym
140
+ end
141
+ return searchable.first if searchable.length == 1
142
+ raise ArgumentError,
143
+ "#{model_class}: cannot infer the vector field (searchable: #{searchable.inspect}); " \
144
+ "pass field:."
145
+ end
146
+
147
+ # @!visibility private
148
+ # Filter paths the SDK pre-filters on for this class, deduplicated and
149
+ # sorted. Tenant path matches the resolution the migrator's
150
+ # augmentation and first-query drift check use.
151
+ def filter_paths(model_class)
152
+ paths = []
153
+ class_name = model_class.parse_class
154
+ if defined?(Parse::Agent::MetadataRegistry)
155
+ Parse::Agent::MetadataRegistry.searchable_filter_fields(class_name).each do |f|
156
+ paths << storage_path(model_class, f)
157
+ end
158
+ rule = Parse::Agent::MetadataRegistry.tenant_scope_rule(class_name)
159
+ paths << wire_name(model_class, rule[:field]) if rule
160
+ end
161
+ paths.compact.uniq.sort
162
+ end
163
+
164
+ # @!visibility private
165
+ def storage_path(model_class, field)
166
+ wire = wire_name(model_class, field)
167
+ type = model_class.respond_to?(:fields) ? model_class.fields[field.to_sym] : nil
168
+ type == :pointer ? "_p_#{wire}" : wire
169
+ end
170
+
171
+ # @!visibility private
172
+ def wire_name(model_class, field)
173
+ sym = field.to_sym
174
+ fmap = model_class.respond_to?(:field_map) ? model_class.field_map : {}
175
+ (fmap[sym] || sym.to_s.columnize).to_s
176
+ end
177
+
178
+ # @!visibility private
179
+ def order_keys(entry)
180
+ VECTOR_KEY_ORDER.each_with_object({}) { |k, h| h[k] = entry[k] if entry.key?(k) }
181
+ end
182
+
183
+ # @!visibility private
184
+ def definition_of(value)
185
+ return {} unless value.is_a?(Hash)
186
+ inner = value["latestDefinition"] || value[:latestDefinition] ||
187
+ value[:definition] || value["definition"]
188
+ inner.is_a?(Hash) ? inner : value
189
+ end
190
+
191
+ # @!visibility private
192
+ def fields_of(defn)
193
+ Array(defn["fields"] || defn[:fields]).select { |f| f.is_a?(Hash) }
194
+ end
195
+
196
+ # @!visibility private
197
+ def vector_entry(defn, path: nil)
198
+ vectors = fields_of(defn).select { |f| (f["type"] || f[:type]).to_s == "vector" }
199
+ entry = (path && vectors.find { |f| (f["path"] || f[:path]).to_s == path.to_s }) || vectors.first || {}
200
+ entry.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
201
+ end
202
+
203
+ # @!visibility private
204
+ def filter_paths_of(defn)
205
+ fields_of(defn).select { |f| (f["type"] || f[:type]).to_s == "filter" }
206
+ .map { |f| (f["path"] || f[:path]).to_s }.uniq.sort
207
+ end
208
+
209
+ # @!visibility private
210
+ # An absent `quantization` means none on both sides; numbers compare
211
+ # as integers; everything else as strings.
212
+ def normalize_vector_value(key, value)
213
+ case key
214
+ when "quantization"
215
+ value.nil? || value.to_s.empty? ? "none" : value.to_s
216
+ when "numDimensions"
217
+ # A malformed live value is reported as drift, not raised.
218
+ value.nil? ? nil : (Integer(value) rescue value.to_s)
219
+ else
220
+ value&.to_s
221
+ end
222
+ end
223
+ end
224
+ end
225
+
226
+ module Schema
227
+ class << self
228
+ # Generate the Atlas `vectorSearch` index definition for a model's
229
+ # `:vector` field. See {Parse::VectorSearch::IndexDefinition.build}.
230
+ #
231
+ # @return [Hash]
232
+ def vector_index_definition(model_class, field: nil)
233
+ Parse::VectorSearch::IndexDefinition.build(model_class, field: field)
234
+ end
235
+ end
236
+ end
237
+ end
@@ -5,6 +5,7 @@ require_relative "pipeline_security"
5
5
  require_relative "acl_scope"
6
6
  require_relative "clp_scope"
7
7
  require_relative "mongodb"
8
+ require_relative "atlas_search/protected_paths"
8
9
 
9
10
  module Parse
10
11
  # Atlas Vector Search entry point. Routes through `Parse::MongoDB`
@@ -46,8 +47,10 @@ module Parse
46
47
  #
47
48
  # * CLP `find` boundary check — refuses calls the equivalent REST
48
49
  # find would refuse.
49
- # * Optional `pointerFields` post-filter — drops rows that don't
50
- # 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.
51
54
  # * Post-`$vectorSearch` ACL `$match` injection (Parse Server's
52
55
  # `_rperm` predicate).
53
56
  # * Post-fetch `protectedFields` redaction.
@@ -123,9 +126,10 @@ module Parse
123
126
  #
124
127
  # * `candidate_limit` / `num_candidates` — the requested window.
125
128
  # * `post_filter_count` — rows returned by the pipeline, i.e. after
126
- # the server-side ACL `$match` and any caller `filter`.
129
+ # the server-side ACL `$match`, the pointerFields `$match`, and any
130
+ # caller `filter`.
127
131
  # * `post_pointer_count` — rows left after client-side redaction and
128
- # pointer-field filtering.
132
+ # the defense-in-depth pointer-field re-check.
129
133
  # * `returned_count` — rows handed back, after trimming to `k`.
130
134
  # * `underfilled` — the caller received fewer than `k`.
131
135
  #
@@ -136,6 +140,10 @@ module Parse
136
140
  # Accepted {.index_drift_policy} values.
137
141
  INDEX_DRIFT_POLICIES = %i[warn raise ignore].freeze
138
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
+
139
147
  class << self
140
148
  # Policy applied when first-query index verification (see
141
149
  # {Parse::Core::VectorSearchable}) finds the deployed Atlas
@@ -306,6 +314,8 @@ module Parse
306
314
  protected_fields = Parse::CLPScope.protected_fields_for(
307
315
  collection_name, resolution.permission_strings,
308
316
  )
317
+ assert_protected_fields_untouched!(collection_name, path, filter, vector_filter,
318
+ protected_fields, resolution)
309
319
 
310
320
  vs_stage = {
311
321
  "index" => index_name.to_s,
@@ -316,7 +326,14 @@ module Parse
316
326
  # result set is trimmed to `k` after enforcement runs.
317
327
  "limit" => candidate_limit,
318
328
  }
319
- 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
320
337
  pipeline = [{ "$vectorSearch" => vs_stage }]
321
338
 
322
339
  pipeline << {
@@ -334,12 +351,39 @@ module Parse
334
351
  unless resolution.master?
335
352
  acl_match = Parse::ACLScope.match_stage_for(resolution)
336
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
337
364
  end
338
365
 
339
366
  pipeline << { "$match" => filter } if filter
340
367
 
341
- raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
342
- 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
343
387
  # Already past the server-side ACL `$match` and any caller
344
388
  # `filter` — NOT the number $vectorSearch emitted.
345
389
  post_filter_count = raw_results.length
@@ -350,6 +394,8 @@ module Parse
350
394
  unless resolution.master?
351
395
  Parse::ACLScope.redact_results!(raw_results, resolution)
352
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.
353
399
  if pointer_fields
354
400
  raw_results = Parse::CLPScope.filter_by_pointer_fields(
355
401
  raw_results, pointer_fields, resolution.user_id,
@@ -439,6 +485,52 @@ module Parse
439
485
  # @return [String, nil]
440
486
  attr_accessor :default_index
441
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
+
442
534
  private
443
535
 
444
536
  def require_available!
@@ -453,31 +545,173 @@ module Parse
453
545
  # scope, refuse the call when the resolved claim set can't
454
546
  # `find` on the collection. Mirrors `Parse::AtlasSearch.search`.
455
547
  def assert_clp_find!(collection_name, resolution)
456
- return if resolution.nil? || resolution.master?
457
- unless Parse::CLPScope.permits?(collection_name, :find, resolution.permission_strings)
458
- raise Parse::CLPScope::Denied.new(
459
- collection_name, :find,
460
- "CLP refuses find on '#{collection_name}' for the current VectorSearch scope.",
548
+ # Same CLP branch evaluation as Parse::MongoDB.aggregate (public,
549
+ # user, and role grants first; then pointerFields / readUserFields).
550
+ # Raises Parse::CLPScope::Denied when the scope cannot find at all.
551
+ Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
552
+ label: "VectorSearch")
553
+ nil
554
+ end
555
+
556
+ # Resolve and return pointerFields for `find` on the collection.
557
+ # Refuse a scoped vector search that lets a protected field decide
558
+ # which rows match or how they rank: a protected vector `field:`, a
559
+ # `filter:` / `vector_filter:` predicate keyed on a protected field
560
+ # (top level or under $and/$or/$nor/$not, dotted or `_p_` form), or
561
+ # an `$expr` reference to one. The output strip alone does not close
562
+ # that oracle. Master scopes and classes with nothing protected are
563
+ # unaffected.
564
+ #
565
+ # @raise [Parse::CLPScope::Denied]
566
+ def assert_protected_fields_untouched!(collection_name, path, filter, vector_filter,
567
+ protected_fields, resolution)
568
+ paths = Parse::AtlasSearch::ProtectedPaths
569
+ return unless paths.enforce?(resolution, protected_fields)
570
+ paths.assert_paths_allowed!(path, protected_fields, resolution,
571
+ collection_name: collection_name,
572
+ method_name: "Parse::VectorSearch.search",
573
+ what: "vector field")
574
+ [filter, vector_filter].each do |f|
575
+ next if f.nil? || f.empty?
576
+ Parse::PipelineSecurity.refuse_protected_field_references!(
577
+ [{ "$match" => f }], collection_name, resolution,
461
578
  )
579
+ paths.assert_filter_allowed!(f, protected_fields, resolution,
580
+ collection_name: collection_name,
581
+ method_name: "Parse::VectorSearch.search")
462
582
  end
583
+ nil
463
584
  end
464
585
 
465
- # Resolve and return pointerFields for `find` on the collection.
466
586
  # Raises CLPScope::Denied when pointerFields is set but the
467
587
  # current scope has no user_id (acl_role-only / public agents).
468
588
  # Returns nil when master-mode or no pointerFields entry exists.
469
589
  def resolve_pointer_fields!(collection_name, resolution)
470
- return nil if resolution.nil? || resolution.master?
471
- pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
472
- return nil if pointer_fields.nil?
473
- if resolution.user_id.nil?
474
- raise Parse::CLPScope::Denied.new(
475
- collection_name, :find,
476
- "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
477
- "but the current VectorSearch scope has no user_id.",
478
- )
590
+ # nil when a public, user, or role grant already permits every row
591
+ # (Parse Server ignores pointer permissions then); otherwise the
592
+ # pointerFields plus readUserFields the rows must match. The older
593
+ # permits? / pointer_fields_for pair missed readUserFields entirely
594
+ # and over-restricted a public grant that also listed pointerFields.
595
+ Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
596
+ label: "VectorSearch")
597
+ end
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
479
714
  end
480
- pointer_fields
481
715
  end
482
716
 
483
717
  # Execute the pipeline directly against the MongoDB collection.
@@ -501,3 +735,5 @@ module Parse
501
735
  @default_index = nil
502
736
  end
503
737
  end
738
+
739
+ require_relative "vector_search/index_definition"