parse-stack-next 5.6.0 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -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 +7 -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 +219 -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/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -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
 
@@ -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
@@ -68,29 +68,29 @@ module Parse
68
68
  class RateLimitError < Error; end
69
69
  class TransientError < Error; end
70
70
 
71
- DEFAULT_BASE_URL = "https://api.cohere.com/v1"
72
- DEFAULT_MODEL = "embed-english-v3.0"
73
- DEFAULT_TIMEOUT = 30
71
+ DEFAULT_BASE_URL = "https://api.cohere.com/v1"
72
+ DEFAULT_MODEL = "embed-english-v3.0"
73
+ DEFAULT_TIMEOUT = 30
74
74
  DEFAULT_OPEN_TIMEOUT = 5
75
75
  DEFAULT_MAX_RETRIES = 3
76
76
  # Cohere documents a hard cap of 96 inputs per `/embed` call.
77
- DEFAULT_BATCH_SIZE = 96
78
- MAX_RESPONSE_BYTES = 16 * 1024 * 1024
77
+ DEFAULT_BATCH_SIZE = 96
78
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
79
79
 
80
80
  MODEL_DEFAULT_DIMENSIONS = {
81
- "embed-v4.0" => 1536,
82
- "embed-english-v3.0" => 1024,
83
- "embed-multilingual-v3.0" => 1024,
84
- "embed-english-light-v3.0" => 384,
85
- "embed-multilingual-light-v3.0" => 384,
81
+ "embed-v4.0" => 1536,
82
+ "embed-english-v3.0" => 1024,
83
+ "embed-multilingual-v3.0" => 1024,
84
+ "embed-english-light-v3.0" => 384,
85
+ "embed-multilingual-light-v3.0" => 384,
86
86
  }.freeze
87
87
 
88
88
  MODEL_MAX_INPUT_TOKENS = {
89
- "embed-v4.0" => 128_000,
90
- "embed-english-v3.0" => 512,
91
- "embed-multilingual-v3.0" => 512,
92
- "embed-english-light-v3.0" => 512,
93
- "embed-multilingual-light-v3.0" => 512,
89
+ "embed-v4.0" => 128_000,
90
+ "embed-english-v3.0" => 512,
91
+ "embed-multilingual-v3.0" => 512,
92
+ "embed-english-light-v3.0" => 512,
93
+ "embed-multilingual-light-v3.0" => 512,
94
94
  }.freeze
95
95
 
96
96
  # Models that accept Cohere's `output_dimension` Matryoshka
@@ -115,10 +115,10 @@ module Parse
115
115
  # `:unknown_type` to `"search_document"` would mask cache-key
116
116
  # bugs in higher layers (the value participates in cache keys).
117
117
  INPUT_TYPE_WIRE_VALUES = {
118
- search_query: "search_query",
118
+ search_query: "search_query",
119
119
  search_document: "search_document",
120
- classification: "classification",
121
- clustering: "clustering",
120
+ classification: "classification",
121
+ clustering: "clustering",
122
122
  }.freeze
123
123
 
124
124
  # @param api_key [String] required. Sent as `Authorization: Bearer …`.
@@ -462,7 +462,7 @@ module Parse
462
462
  next
463
463
  end
464
464
  raise BadRequestError,
465
- "Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?('/') ? path : "/#{path}"}."
465
+ "Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?("/") ? path : "/#{path}"}."
466
466
  end
467
467
  end
468
468
 
@@ -498,8 +498,7 @@ module Parse
498
498
  "Parse::Embeddings::Cohere: response body is not a JSON object."
499
499
  end
500
500
  embeddings = payload["embeddings"]
501
- vectors =
502
- case embeddings
501
+ vectors = case embeddings
503
502
  when Hash
504
503
  f = embeddings["float"]
505
504
  unless f.is_a?(Array)
@@ -521,7 +520,7 @@ module Parse
521
520
  end
522
521
 
523
522
  def backoff_seconds(attempt)
524
- [0.5 * (2**(attempt - 1)), 30.0].min
523
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
525
524
  end
526
525
 
527
526
  def retry_after_seconds(response)
@@ -56,6 +56,7 @@ module Parse
56
56
  class InvalidImageType < Parse::Embeddings::Error
57
57
  # @return [Symbol] failure-mode tag.
58
58
  attr_reader :reason
59
+
59
60
  def initialize(reason, message)
60
61
  @reason = reason
61
62
  super(message)
@@ -76,6 +77,7 @@ module Parse
76
77
  "#<Parse::Embeddings::ImageFetch::FetchedImage mime_type=#{mime_type.inspect} " \
77
78
  "bytes=#{bytes.respond_to?(:bytesize) ? bytes.bytesize : 0} url=#{url.inspect}>"
78
79
  end
80
+
79
81
  alias_method :to_s, :inspect
80
82
  end
81
83
 
@@ -88,11 +90,11 @@ module Parse
88
90
  # against the sniffed type. Extensions not listed here are ignored
89
91
  # (the magic bytes alone govern).
90
92
  EXTENSION_MIME = {
91
- ".jpg" => "image/jpeg",
93
+ ".jpg" => "image/jpeg",
92
94
  ".jpeg" => "image/jpeg",
93
- ".jpe" => "image/jpeg",
94
- ".png" => "image/png",
95
- ".gif" => "image/gif",
95
+ ".jpe" => "image/jpeg",
96
+ ".png" => "image/png",
97
+ ".gif" => "image/gif",
96
98
  ".webp" => "image/webp",
97
99
  }.freeze
98
100
 
@@ -109,8 +111,8 @@ module Parse
109
111
  return nil unless bytes.is_a?(String) && bytes.bytesize >= 12
110
112
  b = bytes.byteslice(0, 16).force_encoding(Encoding::BINARY)
111
113
  return "image/jpeg" if b.start_with?("\xFF\xD8\xFF".b)
112
- return "image/png" if b.start_with?("\x89PNG\r\n\x1A\n".b)
113
- return "image/gif" if b.start_with?("GIF87a".b) || b.start_with?("GIF89a".b)
114
+ return "image/png" if b.start_with?("\x89PNG\r\n\x1A\n".b)
115
+ return "image/gif" if b.start_with?("GIF87a".b) || b.start_with?("GIF89a".b)
114
116
  if b.start_with?("RIFF".b) && b.byteslice(8, 4) == "WEBP".b
115
117
  return "image/webp"
116
118
  end
@@ -177,26 +179,26 @@ module Parse
177
179
  def verify!(bytes, url: nil)
178
180
  if bytes.nil? || bytes.empty?
179
181
  raise InvalidImageType.new(:empty,
180
- "Parse::Embeddings::ImageFetch: downloaded body is empty.")
182
+ "Parse::Embeddings::ImageFetch: downloaded body is empty.")
181
183
  end
182
184
  mime = sniff_mime(bytes)
183
185
  if mime.nil?
184
186
  raise InvalidImageType.new(:unknown_magic,
185
- "Parse::Embeddings::ImageFetch: leading bytes match no supported image " \
186
- "format (JPEG/PNG/GIF/WebP). The Content-Type header is not consulted — " \
187
- "unrecognized content is refused outright.")
187
+ "Parse::Embeddings::ImageFetch: leading bytes match no supported image " \
188
+ "format (JPEG/PNG/GIF/WebP). The Content-Type header is not consulted — " \
189
+ "unrecognized content is refused outright.")
188
190
  end
189
191
  allowed = Parse::Embeddings.allowed_image_types
190
192
  unless allowed.include?(mime)
191
193
  raise InvalidImageType.new(:type_not_allowed,
192
- "Parse::Embeddings::ImageFetch: sniffed type #{mime.inspect} is not in " \
193
- "Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
194
+ "Parse::Embeddings::ImageFetch: sniffed type #{mime.inspect} is not in " \
195
+ "Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
194
196
  end
195
197
  ext_mime = extension_mime(url)
196
198
  if ext_mime && ext_mime != mime
197
199
  raise InvalidImageType.new(:extension_mismatch,
198
- "Parse::Embeddings::ImageFetch: URL extension implies #{ext_mime.inspect} " \
199
- "but the magic bytes are #{mime.inspect} — refusing MIME-laundered content.")
200
+ "Parse::Embeddings::ImageFetch: URL extension implies #{ext_mime.inspect} " \
201
+ "but the magic bytes are #{mime.inspect} — refusing MIME-laundered content.")
200
202
  end
201
203
  mime
202
204
  end
@@ -210,10 +212,10 @@ module Parse
210
212
  def extension_mime(url)
211
213
  return nil unless url.is_a?(String)
212
214
  path = begin
213
- URI.parse(url).path.to_s
214
- rescue URI::InvalidURIError
215
- return nil
216
- end
215
+ URI.parse(url).path.to_s
216
+ rescue URI::InvalidURIError
217
+ return nil
218
+ end
217
219
  dot = path.rindex(".")
218
220
  return nil if dot.nil?
219
221
  EXTENSION_MIME[path[dot..].to_s.downcase]
@@ -231,10 +233,9 @@ module Parse
231
233
  # @param mime [String] sniffed MIME type.
232
234
  # @return [String] bytes with metadata removed.
233
235
  def strip_metadata(bytes, mime)
234
- stripped =
235
- case mime
236
+ stripped = case mime
236
237
  when "image/jpeg" then strip_jpeg_app1(bytes)
237
- when "image/png" then strip_png_exif(bytes)
238
+ when "image/png" then strip_png_exif(bytes)
238
239
  when "image/webp" then strip_webp_metadata(bytes)
239
240
  else return bytes
240
241
  end