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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +298 -21
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +91 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- 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
|
data/lib/parse/vector_search.rb
CHANGED
|
@@ -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
|
-
# *
|
|
50
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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"
|