parse-stack-next 5.5.6 → 5.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +634 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +17 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +167 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/binding_audit.rb +211 -0
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +137 -0
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +40 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +170 -0
- data/lib/parse/embeddings/video_source.rb +121 -0
- data/lib/parse/embeddings/voyage.rb +554 -143
- data/lib/parse/embeddings.rb +83 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +97 -15
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +43 -19
- data/lib/parse/model/core/fetching.rb +2 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +39 -3
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +34 -8
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +132 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +166 -33
- data/lib/parse/vector_search.rb +141 -13
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +22 -4
- data/parse-stack-next.gemspec +6 -6
- metadata +14 -1
data/lib/parse/clp_scope.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Parse
|
|
|
11
11
|
|
|
12
12
|
def initialize(class_name, operation, reason = nil)
|
|
13
13
|
@class_name = class_name
|
|
14
|
-
@operation
|
|
14
|
+
@operation = operation
|
|
15
15
|
super(reason || "CLP denied: #{operation} on #{class_name}")
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -44,6 +44,31 @@ module Parse
|
|
|
44
44
|
# branch on `kind` before dereferencing.
|
|
45
45
|
CacheEntry = Struct.new(:kind, :clp, :fetched_at, keyword_init: true)
|
|
46
46
|
|
|
47
|
+
# Result of evaluating a single CLP branch for object-access inspection.
|
|
48
|
+
# This is intentionally richer than {permits?}: Parse Server only applies
|
|
49
|
+
# pointer permissions when no public/user/role branch already grants the
|
|
50
|
+
# operation, and callers must then inspect the target row before answering.
|
|
51
|
+
AccessEvaluation = Struct.new(
|
|
52
|
+
:status, :via, :pointer_fields, :role_claims, :reason,
|
|
53
|
+
keyword_init: true,
|
|
54
|
+
) do
|
|
55
|
+
def allowed?
|
|
56
|
+
status == :allowed
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def denied?
|
|
60
|
+
status == :denied
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def unknown?
|
|
64
|
+
status == :unknown
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def row_check_required?
|
|
68
|
+
status == :allowed && pointer_fields&.any?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
47
72
|
# Positive-cache TTL (seconds): how long a successful schema fetch
|
|
48
73
|
# is reused. Mirrors the previous module-level `@cache_ttl` knob;
|
|
49
74
|
# kept identical to preserve backwards-compatible cache behavior.
|
|
@@ -64,11 +89,11 @@ module Parse
|
|
|
64
89
|
class << self
|
|
65
90
|
attr_accessor :cache_ttl, :schema_client
|
|
66
91
|
|
|
67
|
-
def permits?(class_name, op, permission_strings)
|
|
92
|
+
def permits?(class_name, op, permission_strings, client: nil)
|
|
68
93
|
return true if permission_strings.nil? # master-key bypass
|
|
69
94
|
return true unless OPERATIONS.include?(op)
|
|
70
95
|
|
|
71
|
-
entry = fetch(class_name)
|
|
96
|
+
entry = fetch(class_name, client: client)
|
|
72
97
|
# `fetch` never returns nil now — it returns an `:unresolvable`
|
|
73
98
|
# CacheEntry on failure so callers must branch on `kind`.
|
|
74
99
|
case entry.kind
|
|
@@ -120,14 +145,14 @@ module Parse
|
|
|
120
145
|
false
|
|
121
146
|
end
|
|
122
147
|
|
|
123
|
-
def assert_permitted!(class_name, op, permission_strings)
|
|
124
|
-
return if permits?(class_name, op, permission_strings)
|
|
148
|
+
def assert_permitted!(class_name, op, permission_strings, client: nil)
|
|
149
|
+
return if permits?(class_name, op, permission_strings, client: client)
|
|
125
150
|
raise Denied.new(class_name, op,
|
|
126
|
-
|
|
151
|
+
"CLP refuses #{op} on '#{class_name}' for the current scope.")
|
|
127
152
|
end
|
|
128
153
|
|
|
129
|
-
def pointer_fields_for(class_name, op)
|
|
130
|
-
entry = fetch(class_name)
|
|
154
|
+
def pointer_fields_for(class_name, op, client: nil)
|
|
155
|
+
entry = fetch(class_name, client: client)
|
|
131
156
|
# No CLP at all, or schema unresolvable: there's no
|
|
132
157
|
# pointerFields constraint to apply. (For :unresolvable the
|
|
133
158
|
# caller's `permits?` already failed closed; this helper just
|
|
@@ -141,10 +166,124 @@ module Parse
|
|
|
141
166
|
arr.empty? ? nil : arr
|
|
142
167
|
end
|
|
143
168
|
|
|
144
|
-
|
|
169
|
+
# Return Parse Server's top-level `readUserFields` or
|
|
170
|
+
# `writeUserFields` row constraint for an operation. Unlike an
|
|
171
|
+
# operation's `pointerFields` grant, these keys live alongside the
|
|
172
|
+
# operation maps in `classLevelPermissions` and therefore need their
|
|
173
|
+
# own lookup.
|
|
174
|
+
#
|
|
175
|
+
# @param class_name [String] Parse class name.
|
|
176
|
+
# @param op [Symbol] CLP operation.
|
|
177
|
+
# @return [Array<String>, nil] pointer field names, or nil when the
|
|
178
|
+
# operation has no corresponding user-field constraint.
|
|
179
|
+
def user_fields_for(class_name, op, client: nil)
|
|
180
|
+
key = case op.to_sym
|
|
181
|
+
when :find, :get, :count then "readUserFields"
|
|
182
|
+
when :create, :update, :delete then "writeUserFields"
|
|
183
|
+
end
|
|
184
|
+
return nil if key.nil?
|
|
185
|
+
|
|
186
|
+
entry = fetch(class_name, client: client)
|
|
187
|
+
return nil if entry.kind == :no_clp || entry.kind == :unresolvable
|
|
188
|
+
|
|
189
|
+
fields = entry.clp[key] || entry.clp[key.to_sym]
|
|
190
|
+
arr = Array(fields).map(&:to_s)
|
|
191
|
+
arr.empty? ? nil : arr
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Evaluate the same mutually-exclusive CLP branches Parse Server uses
|
|
195
|
+
# before applying a query to a particular row. Public, direct-user, and
|
|
196
|
+
# role grants bypass pointer constraints. `requiresAuthentication` does
|
|
197
|
+
# not: when pointer/user fields exist, a concrete authenticated `_User`
|
|
198
|
+
# id must still match the row.
|
|
199
|
+
#
|
|
200
|
+
# Unlike {permits?}, schema lookup failures are reported as `:unknown`
|
|
201
|
+
# rather than collapsed into a denial. Authorization-enforcing callers
|
|
202
|
+
# can still fail closed by accepting only {AccessEvaluation#allowed?}.
|
|
203
|
+
#
|
|
204
|
+
# @param class_name [String] Parse class name.
|
|
205
|
+
# @param op [Symbol] one of {OPERATIONS}.
|
|
206
|
+
# @param claims [Enumerable<String>] public, user, and role claims.
|
|
207
|
+
# @param authenticated [Boolean] whether authentication is established.
|
|
208
|
+
# @param user_id [String, nil] concrete authenticated `_User.objectId`.
|
|
209
|
+
# @param client [Parse::Client] application whose schema owns the CLP.
|
|
210
|
+
# @return [AccessEvaluation]
|
|
211
|
+
def evaluate_access(class_name, op, claims:, authenticated:, user_id: nil, client: nil)
|
|
212
|
+
op = op.to_sym
|
|
213
|
+
unless OPERATIONS.include?(op)
|
|
214
|
+
return access_evaluation(:unknown, reason: :unsupported_operation)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
entry = fetch(class_name, client: client)
|
|
218
|
+
case entry.kind
|
|
219
|
+
when :unresolvable
|
|
220
|
+
return access_evaluation(:unknown, reason: :clp_unresolvable)
|
|
221
|
+
when :no_clp
|
|
222
|
+
return access_evaluation(:allowed, via: :public_default)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
op_map = entry.clp[op.to_s] || entry.clp[op]
|
|
226
|
+
# Parse Server treats an omitted operation map as public. Because that
|
|
227
|
+
# is already a base grant, grouped pointer fields do not narrow it.
|
|
228
|
+
return access_evaluation(:allowed, via: :public_default) if op_map.nil?
|
|
229
|
+
unless op_map.is_a?(Hash)
|
|
230
|
+
return access_evaluation(:unknown, reason: :malformed_clp)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
claim_set = claims.is_a?(Set) ? claims : Set.new(Array(claims).map(&:to_s))
|
|
234
|
+
role_claims = op_map.each_with_object([]) do |(principal, allowed), memo|
|
|
235
|
+
key = principal.to_s
|
|
236
|
+
memo << key if allowed == true && key.start_with?("role:")
|
|
237
|
+
end.freeze
|
|
238
|
+
|
|
239
|
+
if op_map["*"] == true || op_map[:"*"] == true
|
|
240
|
+
return access_evaluation(:allowed, via: :public, role_claims: role_claims)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
direct_claim = op_map.find do |principal, allowed|
|
|
244
|
+
key = principal.to_s
|
|
245
|
+
allowed == true && key != "*" && key != "requiresAuthentication" &&
|
|
246
|
+
key != "pointerFields" && claim_set.include?(key)
|
|
247
|
+
end
|
|
248
|
+
if direct_claim
|
|
249
|
+
return access_evaluation(
|
|
250
|
+
:allowed,
|
|
251
|
+
via: direct_claim.first.to_s.start_with?("role:") ? :role : :user,
|
|
252
|
+
role_claims: role_claims,
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
pointer_fields = pointer_fields_from(entry.clp, op)
|
|
257
|
+
requires_authentication =
|
|
258
|
+
op_map["requiresAuthentication"] == true || op_map[:requiresAuthentication] == true
|
|
259
|
+
|
|
260
|
+
if pointer_fields.any?
|
|
261
|
+
if authenticated == true && !user_id.to_s.empty?
|
|
262
|
+
return access_evaluation(
|
|
263
|
+
:allowed,
|
|
264
|
+
via: :pointer,
|
|
265
|
+
pointer_fields: pointer_fields,
|
|
266
|
+
role_claims: role_claims,
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
status = authenticated == true ? :unknown : :denied
|
|
271
|
+
reason = authenticated == true ? :concrete_user_required : :authentication_required
|
|
272
|
+
return access_evaluation(status, role_claims: role_claims, reason: reason)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if requires_authentication && authenticated == true
|
|
276
|
+
return access_evaluation(:allowed, via: :authenticated, role_claims: role_claims)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
reason = requires_authentication ? :authentication_required : :clp_denied
|
|
280
|
+
access_evaluation(:denied, role_claims: role_claims, reason: reason)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def protected_fields_for(class_name, permission_strings, client: nil)
|
|
145
284
|
return EMPTY_SET if permission_strings.nil?
|
|
146
285
|
|
|
147
|
-
entry = fetch(class_name)
|
|
286
|
+
entry = fetch(class_name, client: client)
|
|
148
287
|
# No CLP / unresolvable: nothing to strip. For :unresolvable,
|
|
149
288
|
# `permits?` already refused the query, so this branch is only
|
|
150
289
|
# reached when callers ask for the protected-fields set directly
|
|
@@ -180,8 +319,15 @@ module Parse
|
|
|
180
319
|
documents.select { |doc| any_pointer_matches?(doc, pointer_fields, user_id.to_s) }
|
|
181
320
|
end
|
|
182
321
|
|
|
183
|
-
def invalidate!(class_name)
|
|
184
|
-
|
|
322
|
+
def invalidate!(class_name, client: nil)
|
|
323
|
+
class_key = class_name.to_s
|
|
324
|
+
@cache_mutex.synchronize do
|
|
325
|
+
if client
|
|
326
|
+
@cache.delete(cache_key(class_key, client))
|
|
327
|
+
else
|
|
328
|
+
@cache.delete_if { |(_scope, cached_class), _entry| cached_class == class_key }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
185
331
|
nil
|
|
186
332
|
end
|
|
187
333
|
|
|
@@ -196,7 +342,11 @@ module Parse
|
|
|
196
342
|
|
|
197
343
|
def cache_stats
|
|
198
344
|
@cache_mutex.synchronize do
|
|
199
|
-
{
|
|
345
|
+
{
|
|
346
|
+
size: @cache.size,
|
|
347
|
+
class_names: @cache.keys.map(&:last).uniq.sort,
|
|
348
|
+
scopes: @cache.keys.map(&:first).uniq.sort,
|
|
349
|
+
}
|
|
200
350
|
end
|
|
201
351
|
end
|
|
202
352
|
|
|
@@ -205,11 +355,11 @@ module Parse
|
|
|
205
355
|
# `:no_clp` (matches the public-default semantics Parse Server
|
|
206
356
|
# exposes when no CLP is configured); a non-empty `clp` is
|
|
207
357
|
# recorded as `:cached_clp` (the standard happy path).
|
|
208
|
-
def __cache_put(class_name, clp:)
|
|
358
|
+
def __cache_put(class_name, clp:, client: nil)
|
|
209
359
|
normalized = clp || {}
|
|
210
360
|
kind = normalized.empty? ? :no_clp : :cached_clp
|
|
211
361
|
entry = CacheEntry.new(kind: kind, clp: normalized, fetched_at: monotonic_now)
|
|
212
|
-
@cache_mutex.synchronize { @cache[class_name
|
|
362
|
+
@cache_mutex.synchronize { @cache[cache_key(class_name, client)] = entry }
|
|
213
363
|
entry
|
|
214
364
|
end
|
|
215
365
|
|
|
@@ -233,23 +383,24 @@ module Parse
|
|
|
233
383
|
# An empty `class_name` short-circuits to an `:unresolvable`
|
|
234
384
|
# entry — `permits?` will refuse the call rather than dispatching
|
|
235
385
|
# `schema("")` to the upstream client.
|
|
236
|
-
def fetch(class_name)
|
|
237
|
-
|
|
238
|
-
return unresolvable_entry if
|
|
386
|
+
def fetch(class_name, client: nil)
|
|
387
|
+
class_key = class_name.to_s
|
|
388
|
+
return unresolvable_entry if class_key.empty?
|
|
389
|
+
|
|
390
|
+
resolved_client = client || schema_client || default_client_safe
|
|
391
|
+
key = cache_key(class_key, resolved_client)
|
|
239
392
|
|
|
240
393
|
cached = @cache_mutex.synchronize { @cache[key] }
|
|
241
394
|
return cached if cached && !stale?(cached)
|
|
242
395
|
|
|
243
|
-
|
|
244
|
-
entry =
|
|
245
|
-
if client.nil?
|
|
396
|
+
entry = if resolved_client.nil?
|
|
246
397
|
# No client configured (Parse.setup never called, etc.) —
|
|
247
398
|
# treat as unresolvable so we fail closed instead of
|
|
248
399
|
# crashing inside the begin block with NoMethodError.
|
|
249
400
|
unresolvable_entry
|
|
250
401
|
else
|
|
251
402
|
begin
|
|
252
|
-
response =
|
|
403
|
+
response = resolved_client.schema(class_key)
|
|
253
404
|
if response&.success?
|
|
254
405
|
schema = response.result || {}
|
|
255
406
|
clp = schema["classLevelPermissions"] || {}
|
|
@@ -318,6 +469,46 @@ module Parse
|
|
|
318
469
|
Parse::Client.client(:default)
|
|
319
470
|
end
|
|
320
471
|
|
|
472
|
+
# Cache CLP by Parse application, not merely by class name. Two clients
|
|
473
|
+
# can legitimately point at different applications that both contain a
|
|
474
|
+
# `Document` class with unrelated permissions.
|
|
475
|
+
def cache_key(class_name, client = nil)
|
|
476
|
+
resolved_client = client || schema_client || default_client_safe
|
|
477
|
+
scope = if resolved_client.nil?
|
|
478
|
+
"client:none"
|
|
479
|
+
elsif resolved_client.respond_to?(:server_url) &&
|
|
480
|
+
resolved_client.respond_to?(:application_id)
|
|
481
|
+
"app:#{resolved_client.server_url}\u0000#{resolved_client.application_id}"
|
|
482
|
+
else
|
|
483
|
+
"client:#{resolved_client.object_id}"
|
|
484
|
+
end
|
|
485
|
+
[scope.freeze, class_name.to_s.freeze].freeze
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def access_evaluation(status, via: nil, pointer_fields: EMPTY_SET,
|
|
489
|
+
role_claims: EMPTY_SET, reason: nil)
|
|
490
|
+
AccessEvaluation.new(
|
|
491
|
+
status: status,
|
|
492
|
+
via: via,
|
|
493
|
+
pointer_fields: Array(pointer_fields).map(&:to_s).uniq.freeze,
|
|
494
|
+
role_claims: Array(role_claims).map(&:to_s).uniq.freeze,
|
|
495
|
+
reason: reason,
|
|
496
|
+
).freeze
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def pointer_fields_from(clp, op)
|
|
500
|
+
op_map = clp[op.to_s] || clp[op]
|
|
501
|
+
per_operation = if op_map.is_a?(Hash)
|
|
502
|
+
op_map["pointerFields"] || op_map[:pointerFields]
|
|
503
|
+
end
|
|
504
|
+
grouped_key = case op.to_sym
|
|
505
|
+
when :find, :get, :count then "readUserFields"
|
|
506
|
+
when :create, :update, :delete then "writeUserFields"
|
|
507
|
+
end
|
|
508
|
+
grouped = grouped_key && (clp[grouped_key] || clp[grouped_key.to_sym])
|
|
509
|
+
(Array(per_operation) + Array(grouped)).map(&:to_s).reject(&:empty?).uniq.freeze
|
|
510
|
+
end
|
|
511
|
+
|
|
321
512
|
def user_identity?(entry)
|
|
322
513
|
s = entry.to_s
|
|
323
514
|
s != "*" && !s.start_with?("role:")
|
|
@@ -339,20 +530,26 @@ module Parse
|
|
|
339
530
|
pointer_fields.any? do |field|
|
|
340
531
|
val = doc[field] || doc[field.to_sym]
|
|
341
532
|
if val.is_a?(Hash)
|
|
342
|
-
return true if val
|
|
533
|
+
return true if user_pointer_matches?(val, user_id)
|
|
343
534
|
elsif val.is_a?(Array)
|
|
344
|
-
return true if val.any?
|
|
345
|
-
v.is_a?(Hash) && (v["objectId"] == user_id || v[:objectId] == user_id)
|
|
346
|
-
end
|
|
535
|
+
return true if val.any? { |v| user_pointer_matches?(v, user_id) }
|
|
347
536
|
end
|
|
348
537
|
mongo_val = doc["_p_#{field}"] || doc[:"_p_#{field}"]
|
|
349
538
|
if mongo_val.is_a?(String) && mongo_val.include?("$")
|
|
350
|
-
|
|
351
|
-
return true if oid == user_id
|
|
539
|
+
klass, oid = mongo_val.split("$", 2)
|
|
540
|
+
return true if klass == Parse::Model::CLASS_USER && oid == user_id
|
|
352
541
|
end
|
|
353
542
|
false
|
|
354
543
|
end
|
|
355
544
|
end
|
|
545
|
+
|
|
546
|
+
def user_pointer_matches?(value, user_id)
|
|
547
|
+
return false unless value.is_a?(Hash)
|
|
548
|
+
type = value["__type"] || value[:__type]
|
|
549
|
+
klass = value["className"] || value[:className]
|
|
550
|
+
oid = value["objectId"] || value[:objectId]
|
|
551
|
+
type == Parse::Model::TYPE_POINTER && klass == Parse::Model::CLASS_USER && oid == user_id
|
|
552
|
+
end
|
|
356
553
|
end
|
|
357
554
|
|
|
358
555
|
@cache_ttl = POSITIVE_TTL
|
data/lib/parse/console.rb
CHANGED
|
@@ -26,7 +26,7 @@ require "timeout"
|
|
|
26
26
|
|
|
27
27
|
module Parse
|
|
28
28
|
module Console
|
|
29
|
-
DEFAULT_WATCH_EVENTS
|
|
29
|
+
DEFAULT_WATCH_EVENTS = [:create, :update, :delete, :enter, :leave].freeze
|
|
30
30
|
DEFAULT_WAIT_FOR_EVENTS = [:create, :enter].freeze
|
|
31
31
|
|
|
32
32
|
module_function
|
|
@@ -120,9 +120,9 @@ module Parse
|
|
|
120
120
|
# @return [Parse::Object] the matched row.
|
|
121
121
|
# @raise [Timeout::Error] when `timeout:` elapses with no match.
|
|
122
122
|
def wait_for(klass, where: {}, on: nil, timeout: nil, fields: nil,
|
|
123
|
-
|
|
123
|
+
session_token: nil, &predicate)
|
|
124
124
|
events = Array(on || DEFAULT_WAIT_FOR_EVENTS).map(&:to_sym)
|
|
125
|
-
queue
|
|
125
|
+
queue = Queue.new
|
|
126
126
|
sub = _open_subscription(klass, where: where, fields: fields, session_token: session_token)
|
|
127
127
|
|
|
128
128
|
events.each do |ev|
|
|
@@ -83,8 +83,8 @@ module Parse
|
|
|
83
83
|
# @param on_progress [#call, nil] callable invoked after each
|
|
84
84
|
# successful batch with `done:, total:, batch_index:, batch_count:`.
|
|
85
85
|
def initialize(provider, batch_size: nil, requests_per_minute: nil,
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
max_attempts: 5, base_delay: 2.0, max_delay: 60.0,
|
|
87
|
+
jitter: 0.25, retry_on: nil, on_progress: nil)
|
|
88
88
|
unless provider.is_a?(Provider)
|
|
89
89
|
raise ArgumentError,
|
|
90
90
|
"Parse::Embeddings::BatchEmbedder expects a Parse::Embeddings::Provider " \
|
|
@@ -164,7 +164,7 @@ module Parse
|
|
|
164
164
|
end
|
|
165
165
|
|
|
166
166
|
def backoff_delay(attempt)
|
|
167
|
-
delay = [@base_delay * (2**(attempt - 1)), @max_delay].min
|
|
167
|
+
delay = [@base_delay * (2 ** (attempt - 1)), @max_delay].min
|
|
168
168
|
delay * (1.0 + rand * @jitter)
|
|
169
169
|
end
|
|
170
170
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Embeddings
|
|
6
|
+
# Checks that a `:vector` property's declared provider binding
|
|
7
|
+
# still matches the provider actually registered under that name.
|
|
8
|
+
#
|
|
9
|
+
# A `:vector` property may declare `provider:`, `model:`, and
|
|
10
|
+
# `dimensions:`. Only `provider:` was ever enforced. `dimensions:`
|
|
11
|
+
# is verified — but only against the vector a provider already
|
|
12
|
+
# returned, i.e. after the call has been made and paid for. And
|
|
13
|
+
# `model:` was never checked at all, which is the dangerous one: two
|
|
14
|
+
# generations of the same model family usually share a width
|
|
15
|
+
# (`voyage-3` and `voyage-3.5` are both 1024), so swapping the
|
|
16
|
+
# registered provider's model silently mixes incompatible
|
|
17
|
+
# embeddings into one index. Nothing raises, recall just quietly
|
|
18
|
+
# degrades, and the damage is only repairable by re-embedding.
|
|
19
|
+
#
|
|
20
|
+
# This module closes both gaps by comparing the declaration against
|
|
21
|
+
# the live provider BEFORE any request is issued.
|
|
22
|
+
#
|
|
23
|
+
# Auditing cannot happen at class-definition time: providers are
|
|
24
|
+
# registered by name and, as {Parse::Core::EmbedManaged} documents,
|
|
25
|
+
# registration may legitimately happen any time before the first
|
|
26
|
+
# save. So the audit runs lazily on each use and is also exposed as
|
|
27
|
+
# {.audit_all!} for an explicit boot-time or CI check.
|
|
28
|
+
module BindingAudit
|
|
29
|
+
# Raised when a property's declared binding disagrees with the
|
|
30
|
+
# registered provider.
|
|
31
|
+
class BindingMismatch < Parse::Embeddings::Error; end
|
|
32
|
+
|
|
33
|
+
# Raised when the audit cannot enumerate the classes it is meant
|
|
34
|
+
# to check. Distinct from {BindingMismatch}: nothing was found to
|
|
35
|
+
# be wrong, but nothing was verified either.
|
|
36
|
+
class DiscoveryFailed < Parse::Embeddings::Error; end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Verify one property binding against a resolved provider.
|
|
40
|
+
#
|
|
41
|
+
# Deliberately NOT memoized. The check is a pair of comparisons
|
|
42
|
+
# against values already in memory, so caching it saves nothing
|
|
43
|
+
# measurable — while any cache key cheap enough to be worth
|
|
44
|
+
# computing (class name, provider object id) can go stale when a
|
|
45
|
+
# class is unloaded and redefined with a changed declaration, or
|
|
46
|
+
# when object ids are recycled. A validator that silently skips
|
|
47
|
+
# after a reload is worse than no validator, so correctness wins
|
|
48
|
+
# over an optimization with no observable benefit.
|
|
49
|
+
#
|
|
50
|
+
# @param klass [Class] the Parse::Object subclass.
|
|
51
|
+
# @param field [Symbol] the `:vector` property name.
|
|
52
|
+
# @param provider [Parse::Embeddings::Provider]
|
|
53
|
+
# @raise [BindingMismatch]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def verify!(klass, field, provider)
|
|
56
|
+
declared = klass.vector_properties[field.to_sym]
|
|
57
|
+
return if declared.nil?
|
|
58
|
+
|
|
59
|
+
check!(klass, field, provider, declared)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Audit every declared binding whose provider is registered.
|
|
64
|
+
# Intended for boot or CI: it surfaces a drifted declaration
|
|
65
|
+
# before a single embedding is written, rather than on the
|
|
66
|
+
# first save that happens to touch it.
|
|
67
|
+
#
|
|
68
|
+
# @param classes [Array<Class>, nil] defaults to every
|
|
69
|
+
# Parse::Object subclass carrying `:vector` properties.
|
|
70
|
+
# @param strict [Boolean] when true, an unregistered provider
|
|
71
|
+
# is itself a failure; otherwise those bindings are skipped
|
|
72
|
+
# (a provider may be registered later in boot).
|
|
73
|
+
# @return [Array<String>] human-readable problems, empty when clean.
|
|
74
|
+
def audit_all!(classes: nil, strict: false)
|
|
75
|
+
problems = []
|
|
76
|
+
begin
|
|
77
|
+
bindings = collect_bindings(classes)
|
|
78
|
+
rescue DiscoveryFailed => e
|
|
79
|
+
return [e.message]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
bindings.each do |klass, field, declared|
|
|
83
|
+
provider_name = declared[:provider]
|
|
84
|
+
next if provider_name.nil?
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
provider = Parse::Embeddings.provider(provider_name)
|
|
88
|
+
rescue Parse::Embeddings::ProviderNotRegistered => e
|
|
89
|
+
problems << "#{klass}##{field}: #{e.message}" if strict
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
check!(klass, field, provider, declared)
|
|
95
|
+
rescue BindingMismatch => e
|
|
96
|
+
problems << e.message
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
problems
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# {.audit_all!} that raises instead of returning problems.
|
|
103
|
+
#
|
|
104
|
+
# @raise [BindingMismatch] when any binding disagrees.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def audit_all_or_raise!(classes: nil, strict: false)
|
|
107
|
+
problems = audit_all!(classes: classes, strict: strict)
|
|
108
|
+
return if problems.empty?
|
|
109
|
+
|
|
110
|
+
raise BindingMismatch,
|
|
111
|
+
"Parse::Embeddings binding audit failed:\n - #{problems.join("\n - ")}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Retained as a no-op for callers that invoked it when this
|
|
115
|
+
# module memoized verdicts.
|
|
116
|
+
# @return [void]
|
|
117
|
+
def reset!
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Read a provider accessor, distinguishing "not implemented"
|
|
124
|
+
# from a legitimate nil. Returns `:unavailable` for the former
|
|
125
|
+
# so {#check!} can fail closed rather than skip the comparison.
|
|
126
|
+
def accessor(provider, method)
|
|
127
|
+
value = provider.public_send(method)
|
|
128
|
+
value.nil? ? :unavailable : value
|
|
129
|
+
rescue NotImplementedError, NoMethodError
|
|
130
|
+
:unavailable
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check!(klass, field, provider, declared)
|
|
134
|
+
declared_model = declared[:model]
|
|
135
|
+
if declared_model
|
|
136
|
+
actual_model = accessor(provider, :model_name)
|
|
137
|
+
# A declaration states a requirement. A provider that cannot
|
|
138
|
+
# answer what model it runs cannot satisfy it, so this fails
|
|
139
|
+
# closed — otherwise a custom provider without `model_name`
|
|
140
|
+
# would write same-width embeddings that are never checked
|
|
141
|
+
# against the declaration at all.
|
|
142
|
+
if actual_model == :unavailable
|
|
143
|
+
raise BindingMismatch,
|
|
144
|
+
"#{klass}##{field} declares model: #{declared_model.inspect} but the " \
|
|
145
|
+
"provider registered as #{declared[:provider].inspect} " \
|
|
146
|
+
"(#{provider.class}) does not report a usable #model_name, so the " \
|
|
147
|
+
"binding cannot be verified. Implement #model_name on the provider, " \
|
|
148
|
+
"or drop `model:` from the property to opt out of the check."
|
|
149
|
+
end
|
|
150
|
+
if declared_model.to_s != actual_model.to_s
|
|
151
|
+
raise BindingMismatch,
|
|
152
|
+
"#{klass}##{field} declares model: #{declared_model.inspect} but the " \
|
|
153
|
+
"provider registered as #{declared[:provider].inspect} is running " \
|
|
154
|
+
"#{actual_model.inspect}. Vectors from different models are not " \
|
|
155
|
+
"comparable; embedding with the current provider would corrupt this " \
|
|
156
|
+
"index. Update the declaration and re-embed, or register the declared " \
|
|
157
|
+
"model."
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
declared_dims = declared[:dimensions]
|
|
162
|
+
if declared_dims
|
|
163
|
+
actual_dims = accessor(provider, :dimensions)
|
|
164
|
+
if actual_dims == :unavailable
|
|
165
|
+
raise BindingMismatch,
|
|
166
|
+
"#{klass}##{field} declares dimensions: #{declared_dims} but the " \
|
|
167
|
+
"provider registered as #{declared[:provider].inspect} " \
|
|
168
|
+
"(#{provider.class}) does not report a usable #dimensions, so the " \
|
|
169
|
+
"binding cannot be verified. #dimensions is required by the provider " \
|
|
170
|
+
"protocol."
|
|
171
|
+
end
|
|
172
|
+
if declared_dims != actual_dims
|
|
173
|
+
raise BindingMismatch,
|
|
174
|
+
"#{klass}##{field} declares dimensions: #{declared_dims} but the provider " \
|
|
175
|
+
"registered as #{declared[:provider].inspect} emits #{actual_dims}-dim " \
|
|
176
|
+
"vectors. Fix the declaration or configure the provider's width before " \
|
|
177
|
+
"embedding."
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def collect_bindings(classes)
|
|
184
|
+
list = classes || default_classes
|
|
185
|
+
list.flat_map do |klass|
|
|
186
|
+
next [] unless klass.respond_to?(:vector_properties)
|
|
187
|
+
klass.vector_properties.map { |field, declared| [klass, field, declared] }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Discovery must NOT swallow its own failure: an empty list from
|
|
192
|
+
# a crashed sweep is indistinguishable from a clean audit, so
|
|
193
|
+
# `audit_all_or_raise!` would report success having checked
|
|
194
|
+
# nothing. Failures propagate as {DiscoveryFailed} and
|
|
195
|
+
# {.audit_all!} converts them into a reported problem.
|
|
196
|
+
def default_classes
|
|
197
|
+
return [] unless defined?(Parse::Object)
|
|
198
|
+
ObjectSpace.each_object(Class).select do |k|
|
|
199
|
+
k < Parse::Object && k.respond_to?(:vector_properties) &&
|
|
200
|
+
!k.vector_properties.empty?
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
raise DiscoveryFailed,
|
|
204
|
+
"Parse::Embeddings::BindingAudit could not enumerate Parse::Object " \
|
|
205
|
+
"subclasses (#{e.class}: #{e.message}); the audit checked nothing. " \
|
|
206
|
+
"Pass `classes:` explicitly to audit a known set."
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -313,15 +313,15 @@ module Parse
|
|
|
313
313
|
# to the other poisons the narrower/wider field.
|
|
314
314
|
def key_for(provider, input, input_type)
|
|
315
315
|
model = begin
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
provider.model_name
|
|
317
|
+
rescue NotImplementedError
|
|
318
|
+
"unknown"
|
|
319
|
+
end
|
|
320
320
|
dims = begin
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
provider.dimensions
|
|
322
|
+
rescue NotImplementedError
|
|
323
|
+
"unknown"
|
|
324
|
+
end
|
|
325
325
|
"#{provider.class.name}|#{model}|#{dims}|#{input_type}|#{Digest::SHA256.hexdigest(input.to_s)}"
|
|
326
326
|
end
|
|
327
327
|
|
|
@@ -347,15 +347,15 @@ module Parse
|
|
|
347
347
|
def instrument_hit(provider, input_type)
|
|
348
348
|
return unless defined?(ActiveSupport::Notifications)
|
|
349
349
|
model = begin
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
350
|
+
provider.model_name
|
|
351
|
+
rescue NotImplementedError
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
354
|
dims = begin
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
355
|
+
provider.dimensions
|
|
356
|
+
rescue NotImplementedError
|
|
357
|
+
nil
|
|
358
|
+
end
|
|
359
359
|
payload = {
|
|
360
360
|
provider: provider.class.name,
|
|
361
361
|
model: model,
|
|
@@ -366,7 +366,7 @@ module Parse
|
|
|
366
366
|
cached: true,
|
|
367
367
|
error: nil,
|
|
368
368
|
}
|
|
369
|
-
ActiveSupport::Notifications.instrument(Provider::AS_NOTIFICATION_NAME, payload) {}
|
|
369
|
+
ActiveSupport::Notifications.instrument(Provider::AS_NOTIFICATION_NAME, payload) { }
|
|
370
370
|
end
|
|
371
371
|
end
|
|
372
372
|
end
|