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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +634 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +17 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/binding_audit.rb +211 -0
  54. data/lib/parse/embeddings/cache.rb +17 -17
  55. data/lib/parse/embeddings/cohere.rb +21 -22
  56. data/lib/parse/embeddings/image_fetch.rb +22 -21
  57. data/lib/parse/embeddings/jina.rb +21 -21
  58. data/lib/parse/embeddings/local_http.rb +6 -7
  59. data/lib/parse/embeddings/media_file.rb +137 -0
  60. data/lib/parse/embeddings/openai.rb +5 -5
  61. data/lib/parse/embeddings/provider.rb +40 -1
  62. data/lib/parse/embeddings/qwen.rb +10 -10
  63. data/lib/parse/embeddings/spend_cap.rb +2 -3
  64. data/lib/parse/embeddings/streaming_body.rb +170 -0
  65. data/lib/parse/embeddings/video_source.rb +121 -0
  66. data/lib/parse/embeddings/voyage.rb +554 -143
  67. data/lib/parse/embeddings.rb +83 -27
  68. data/lib/parse/graphql/type_generator.rb +1 -1
  69. data/lib/parse/graphql.rb +5 -5
  70. data/lib/parse/live_query/client.rb +1 -1
  71. data/lib/parse/live_query.rb +1 -1
  72. data/lib/parse/lock.rb +15 -18
  73. data/lib/parse/lock_backend.rb +1 -1
  74. data/lib/parse/lookup_rewriter.rb +6 -6
  75. data/lib/parse/model/classes/role.rb +227 -28
  76. data/lib/parse/model/classes/user.rb +60 -1
  77. data/lib/parse/model/clp.rb +4 -4
  78. data/lib/parse/model/core/actions.rb +97 -15
  79. data/lib/parse/model/core/create_lock.rb +0 -2
  80. data/lib/parse/model/core/describe.rb +57 -57
  81. data/lib/parse/model/core/embed_managed.rb +43 -19
  82. data/lib/parse/model/core/fetching.rb +2 -0
  83. data/lib/parse/model/core/indexing.rb +14 -14
  84. data/lib/parse/model/core/parse_reference.rb +1 -1
  85. data/lib/parse/model/core/properties.rb +39 -3
  86. data/lib/parse/model/core/querying.rb +1 -1
  87. data/lib/parse/model/core/schema.rb +2 -2
  88. data/lib/parse/model/core/search_indexing.rb +2 -2
  89. data/lib/parse/model/core/vector_searchable.rb +34 -8
  90. data/lib/parse/model/file.rb +21 -15
  91. data/lib/parse/model/geojson.rb +2 -2
  92. data/lib/parse/model/geopoint.rb +1 -0
  93. data/lib/parse/model/object.rb +132 -66
  94. data/lib/parse/model/pointer.rb +2 -0
  95. data/lib/parse/model/polygon.rb +3 -6
  96. data/lib/parse/model/push.rb +2 -2
  97. data/lib/parse/model/vector.rb +3 -1
  98. data/lib/parse/mongodb.rb +512 -282
  99. data/lib/parse/pipeline_security.rb +3 -0
  100. data/lib/parse/query/constraints.rb +46 -46
  101. data/lib/parse/query.rb +146 -59
  102. data/lib/parse/retrieval/agent_tool.rb +34 -20
  103. data/lib/parse/retrieval/chunk.rb +1 -0
  104. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  105. data/lib/parse/retrieval/reranker.rb +3 -4
  106. data/lib/parse/retrieval/retriever.rb +4 -6
  107. data/lib/parse/schema/index_migrator.rb +26 -26
  108. data/lib/parse/schema/search_index_migrator.rb +19 -19
  109. data/lib/parse/stack/tasks.rb +6 -6
  110. data/lib/parse/stack/version.rb +1 -1
  111. data/lib/parse/stack.rb +10 -10
  112. data/lib/parse/vector_search/hybrid.rb +166 -33
  113. data/lib/parse/vector_search.rb +141 -13
  114. data/lib/parse/webhooks/payload.rb +1 -0
  115. data/lib/parse/webhooks/registration.rb +4 -4
  116. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  117. data/lib/parse/webhooks.rb +22 -4
  118. data/parse-stack-next.gemspec +6 -6
  119. metadata +14 -1
@@ -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 = 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
- "CLP refuses #{op} on '#{class_name}' for the current scope.")
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
- def protected_fields_for(class_name, permission_strings)
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
- @cache_mutex.synchronize { @cache.delete(class_name.to_s) }
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
- { size: @cache.size, class_names: @cache.keys.sort }
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.to_s] = entry }
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
- key = class_name.to_s
238
- return unresolvable_entry if key.empty?
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
- client = schema_client || default_client_safe
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 = client.schema(key)
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["objectId"] == user_id || val[:objectId] == user_id
533
+ return true if user_pointer_matches?(val, user_id)
343
534
  elsif val.is_a?(Array)
344
- return true if val.any? do |v|
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
- _cls, oid = mongo_val.split("$", 2)
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 = [:create, :update, :delete, :enter, :leave].freeze
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
- session_token: nil, &predicate)
123
+ session_token: nil, &predicate)
124
124
  events = Array(on || DEFAULT_WAIT_FOR_EVENTS).map(&:to_sym)
125
- queue = Queue.new
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
- max_attempts: 5, base_delay: 2.0, max_delay: 60.0,
87
- jitter: 0.25, retry_on: nil, on_progress: nil)
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
- provider.model_name
317
- rescue NotImplementedError
318
- "unknown"
319
- end
316
+ provider.model_name
317
+ rescue NotImplementedError
318
+ "unknown"
319
+ end
320
320
  dims = begin
321
- provider.dimensions
322
- rescue NotImplementedError
323
- "unknown"
324
- end
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
- provider.model_name
351
- rescue NotImplementedError
352
- nil
353
- end
350
+ provider.model_name
351
+ rescue NotImplementedError
352
+ nil
353
+ end
354
354
  dims = begin
355
- provider.dimensions
356
- rescue NotImplementedError
357
- nil
358
- end
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