parse-stack-next 5.0.0 → 5.1.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  5. data/.github/dependabot.yml +13 -0
  6. data/.github/workflows/codeql.yml +1 -1
  7. data/.github/workflows/docs.yml +3 -3
  8. data/.github/workflows/release.yml +43 -0
  9. data/.github/workflows/ruby.yml +1 -1
  10. data/.gitignore +1 -0
  11. data/.vscode/settings.json +3 -0
  12. data/.yardopts +19 -0
  13. data/CHANGELOG.md +802 -0
  14. data/Gemfile +3 -0
  15. data/Gemfile.lock +8 -5
  16. data/README.md +16 -1
  17. data/Rakefile +5 -1
  18. data/docs/acl_clp_guide.md +553 -0
  19. data/docs/atlas_vector_search_guide.md +123 -22
  20. data/docs/client_sdk_guide.md +201 -5
  21. data/docs/usage_guide.md +21 -0
  22. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  23. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  24. data/lib/parse/agent/tools.rb +153 -1
  25. data/lib/parse/cache/pool.rb +15 -0
  26. data/lib/parse/cache/redis.rb +114 -2
  27. data/lib/parse/client/caching.rb +18 -1
  28. data/lib/parse/client.rb +79 -12
  29. data/lib/parse/embeddings/cohere.rb +143 -6
  30. data/lib/parse/embeddings/provider.rb +20 -2
  31. data/lib/parse/embeddings/voyage.rb +102 -0
  32. data/lib/parse/embeddings.rb +332 -1
  33. data/lib/parse/live_query/client.rb +167 -4
  34. data/lib/parse/live_query/configuration.rb +12 -0
  35. data/lib/parse/live_query/subscription.rb +55 -2
  36. data/lib/parse/live_query.rb +123 -1
  37. data/lib/parse/lock.rb +342 -0
  38. data/lib/parse/lock_backend.rb +308 -0
  39. data/lib/parse/model/classes/audience.rb +5 -0
  40. data/lib/parse/model/classes/installation.rb +122 -0
  41. data/lib/parse/model/classes/job_schedule.rb +3 -1
  42. data/lib/parse/model/classes/job_status.rb +4 -1
  43. data/lib/parse/model/classes/push_status.rb +4 -1
  44. data/lib/parse/model/classes/session.rb +7 -0
  45. data/lib/parse/model/classes/user.rb +204 -0
  46. data/lib/parse/model/core/create_lock.rb +28 -134
  47. data/lib/parse/model/core/embed_managed.rb +162 -13
  48. data/lib/parse/model/core/parse_reference.rb +17 -1
  49. data/lib/parse/model/core/querying.rb +26 -2
  50. data/lib/parse/model/file.rb +523 -18
  51. data/lib/parse/query.rb +31 -1
  52. data/lib/parse/stack/version.rb +1 -1
  53. data/lib/parse/stack.rb +98 -1
  54. data/parse-stack-next.gemspec +2 -2
  55. metadata +19 -7
@@ -225,6 +225,24 @@ module Parse
225
225
  # @return [Array<Parse::Session>] A list of active Parse::Session objects.
226
226
  has_many :active_sessions, as: :session
227
227
 
228
+ # @!attribute installations
229
+ # A `has_many` query-form association resolving to all
230
+ # {Parse::Installation} records whose `user` pointer is this user.
231
+ # Useful for targeted push — e.g. sending a notification to every
232
+ # device the user is signed into. This is a query (no column is
233
+ # stored on `_User`); each access issues a `find` against
234
+ # `_Installation` for `where(user: self)`.
235
+ #
236
+ # **Requires a master-key client.** Parse Server hardcodes
237
+ # `_Installation` `find` to master-key-only at the REST layer, so
238
+ # this association will return an empty array (or fail-closed
239
+ # depending on agent scope) under a session-token-only / sessionless
240
+ # client. The `user` pointer is also not a reliable owner identity
241
+ # — devices outlive sessions and can change users — see
242
+ # {Parse::Installation} for the full caveat list.
243
+ # @return [Array<Parse::Installation>]
244
+ has_many :installations, as: :installation
245
+
228
246
  # CHANGE -- ACLs can be managed
229
247
  # before_save do
230
248
  # # You cannot specify user ACLs.
@@ -278,6 +296,192 @@ module Parse
278
296
  def authdata_trusted?
279
297
  Thread.current[AUTHDATA_TRUST_KEY] == true
280
298
  end
299
+
300
+ # =========================================================================
301
+ # Field-visibility DSL for _User
302
+ # =========================================================================
303
+ #
304
+ # `_User` has two field-visibility flavors that vanilla
305
+ # {Parse::Object.protect_fields} can't express on its own because
306
+ # Parse Server's `protectedFieldsOwnerExempt` option special-cases
307
+ # the owning user (the user sees their own row in full unless the
308
+ # option is disabled). These helpers wrap that pattern.
309
+ #
310
+ # ## Prerequisites
311
+ #
312
+ # 1. Set `protectedFieldsOwnerExempt: false` in your Parse Server
313
+ # startup options. With the historical default `true`, the owning
314
+ # user is silently exempted from every `protectedFields` rule on
315
+ # `_User`, so {.master_only_fields} would still be visible to the
316
+ # user themselves. Parse Server's default for this option is
317
+ # changing to `false` in a future version; until your server
318
+ # adopts that default you must set it explicitly.
319
+ # 2. For {.self_visible_fields}: add a self-pointer field on `_User`
320
+ # that points to the same user, and maintain it from a
321
+ # `beforeSave('_User')` Cloud Code trigger:
322
+ #
323
+ # ```js
324
+ # Parse.Cloud.beforeSave(Parse.User, (req) => {
325
+ # const u = req.object;
326
+ # if (!u.get('self')) u.set('self', u); // self-pointer
327
+ # });
328
+ # ```
329
+ #
330
+ # The SDK cannot install either of those — they're server-side
331
+ # configuration — but the helpers below will warn if they detect
332
+ # they're being mis-applied.
333
+
334
+ # Hide one or more fields from query/get responses for **all**
335
+ # non-master callers, including the owning user themselves.
336
+ # Useful for admin-only metadata living on `_User`
337
+ # (e.g. internal scoring, moderation notes).
338
+ #
339
+ # Requires Parse Server option `protectedFieldsOwnerExempt: false`.
340
+ # With the default `true`, the owning user still sees these fields
341
+ # on their own row.
342
+ #
343
+ # @param fields [Array<Symbol,String>] field names. Use snake_case
344
+ # Ruby property names; they're auto-converted to camelCase.
345
+ # @return [Array<Symbol>] full master-only field list after this call.
346
+ # @example
347
+ # class Parse::User
348
+ # property :my_opinion_of_them, :string
349
+ # master_only_fields :my_opinion_of_them
350
+ # end
351
+ def master_only_fields(*fields)
352
+ @master_only_fields ||= []
353
+ @master_only_fields = (@master_only_fields + fields.flatten.map(&:to_sym)).uniq
354
+ _warn_about_owner_exempt_prereq!
355
+ _rebuild_user_protected_fields!
356
+ @master_only_fields.dup
357
+ end
358
+
359
+ # Hide one or more fields from public/role/other-user callers, but
360
+ # allow the **owning user** to see them. Useful for private profile
361
+ # data that belongs to the user (e.g. preferences, private notes).
362
+ #
363
+ # Requires:
364
+ # * Parse Server option `protectedFieldsOwnerExempt: false`.
365
+ # * A self-pointer field on `_User` (named via `via:`, default
366
+ # `:self`) that is set to the row's own pointer by a
367
+ # `beforeSave('_User')` Cloud Code trigger.
368
+ #
369
+ # @param fields [Array<Symbol,String>] field names. snake_case OK.
370
+ # @param via [Symbol,String] name of the self-pointer field on
371
+ # `_User` (default `:self`).
372
+ # @return [Array<Symbol>]
373
+ # @example
374
+ # class Parse::User
375
+ # property :favorite_color, :string
376
+ # self_visible_fields :favorite_color, via: :self
377
+ # end
378
+ def self_visible_fields(*fields, via: :self)
379
+ @self_visible_fields ||= []
380
+ @self_visible_fields = (@self_visible_fields + fields.flatten.map(&:to_sym)).uniq
381
+ @self_pointer_field = via.to_sym
382
+ _warn_about_owner_exempt_prereq!
383
+ _warn_about_self_pointer_prereq!(via)
384
+ _rebuild_user_protected_fields!
385
+ @self_visible_fields.dup
386
+ end
387
+
388
+ # Override {Parse::Object.protect_fields} on `_User` so that ad-hoc
389
+ # uses (i.e. not through {.master_only_fields} /
390
+ # {.self_visible_fields}) emit a one-time advisory pointing at the
391
+ # higher-level helpers and the `protectedFieldsOwnerExempt` flag.
392
+ # The behavior is otherwise unchanged.
393
+ def protect_fields(pattern, fields)
394
+ _warn_about_user_protect_fields! unless @_user_field_dsl_active
395
+ super
396
+ end
397
+
398
+ # @!visibility private
399
+ def _rebuild_user_protected_fields!
400
+ @master_only_fields ||= []
401
+ @self_visible_fields ||= []
402
+ pointer = @self_pointer_field || :self
403
+ all_hidden = (@master_only_fields + @self_visible_fields).uniq
404
+
405
+ @_user_field_dsl_active = true
406
+ begin
407
+ protect_fields("*", all_hidden) unless all_hidden.empty?
408
+ unless @self_visible_fields.empty?
409
+ protect_fields("userField:#{pointer}", @master_only_fields)
410
+ end
411
+ ensure
412
+ @_user_field_dsl_active = false
413
+ end
414
+ end
415
+
416
+ # @!visibility private
417
+ def _warn_about_user_protect_fields!
418
+ return if @_user_protect_fields_warned
419
+ @_user_protect_fields_warned = true
420
+ _emit_user_field_advisory(
421
+ "[Parse::User] protect_fields was called directly on _User. " \
422
+ "For master-only and owner-visible field patterns prefer " \
423
+ "`Parse::User.master_only_fields` and `Parse::User.self_visible_fields`. " \
424
+ "Either way, ensure Parse Server is started with " \
425
+ "`protectedFieldsOwnerExempt: false` (the historical default `true` — " \
426
+ "changing to `false` in a future Parse Server version — exempts the " \
427
+ "owning user from every protectedFields rule on _User, which silently " \
428
+ "negates these protections for the user's own row).",
429
+ )
430
+ end
431
+
432
+ # @!visibility private
433
+ # Fires once when `master_only_fields` / `self_visible_fields` is first
434
+ # used. Without `protectedFieldsOwnerExempt: false` in Parse Server's
435
+ # startup options, neither helper does what its name promises -- the
436
+ # default `true` silently exempts the owning user from every
437
+ # protectedFields rule on _User. The SDK can't introspect Parse
438
+ # Server's startup options, so we surface this as a one-time advisory
439
+ # at declaration time so it's loud enough to catch before deploy.
440
+ def _warn_about_owner_exempt_prereq!
441
+ return if @_owner_exempt_warned
442
+ @_owner_exempt_warned = true
443
+ _emit_user_field_advisory(
444
+ "[Parse::User] master_only_fields / self_visible_fields require " \
445
+ "Parse Server option `protectedFieldsOwnerExempt: false`. With the " \
446
+ "historical default `true`, the owning user is silently exempted " \
447
+ "from every protectedFields rule on _User, so a field declared " \
448
+ "master-only would still be visible to the user themselves on their " \
449
+ "own row. Parse Server's default for this option is changing to " \
450
+ "`false` in a future version (which makes these helpers work " \
451
+ "without extra config), but until your server adopts that default " \
452
+ "you must set `protectedFieldsOwnerExempt: false` in your " \
453
+ "ParseServer options BEFORE deploying. See docs/acl_clp_guide.md §4.2.",
454
+ )
455
+ end
456
+
457
+ # @!visibility private
458
+ # Fires once when `self_visible_fields` is first used. The Parse
459
+ # Server side requires (a) a self-pointer field on _User populated
460
+ # by a beforeSave('_User') trigger, AND (b) a one-shot backfill on
461
+ # any pre-existing user rows so the pointer is set before the
462
+ # `userField:<via>` group matches them.
463
+ def _warn_about_self_pointer_prereq!(via)
464
+ return if @_self_pointer_warned
465
+ @_self_pointer_warned = true
466
+ _emit_user_field_advisory(
467
+ "[Parse::User] self_visible_fields(via: :#{via}) requires a " \
468
+ "self-pointer field named `#{via}` on _User pointing at the same " \
469
+ "row, populated by a beforeSave('_User') Cloud Code trigger. " \
470
+ "Existing user rows ALSO need a one-shot backfill (the trigger " \
471
+ "only fires on save) -- without it, those rows never match the " \
472
+ "`userField:#{via}` group and the field stays hidden from the " \
473
+ "user themselves. See docs/acl_clp_guide.md §4.2.",
474
+ )
475
+ end
476
+
477
+ # @!visibility private
478
+ def _emit_user_field_advisory(msg)
479
+ if Parse.respond_to?(:logger) && Parse.logger
480
+ Parse.logger.warn(msg)
481
+ else
482
+ Kernel.warn(msg)
483
+ end
484
+ end
281
485
  end
282
486
 
283
487
  # @!visibility private
@@ -6,6 +6,7 @@ require "openssl"
6
6
  require "json"
7
7
  require "securerandom"
8
8
  require "monitor"
9
+ require_relative "../../lock_backend"
9
10
 
10
11
  module Parse
11
12
  # Mutual-exclusion primitive for `first_or_create!` / `create_or_update!` to
@@ -70,25 +71,29 @@ module Parse
70
71
  master_key: master_key,
71
72
  )
72
73
 
73
- store = lock_store
74
- if degraded_store?(store)
75
- handle_degraded(on_degraded, key)
76
- return process_mutex(key).synchronize(&block)
74
+ store = LockBackend.lock_store
75
+ if LockBackend.degraded_store?(store)
76
+ LockBackend.handle_degraded(
77
+ on_degraded, key,
78
+ source: "Parse::CreateLock",
79
+ unavailable_error: Parse::CreateLockUnavailableError,
80
+ )
81
+ return LockBackend.process_mutex(key).synchronize(&block)
77
82
  end
78
83
 
79
84
  owner = SecureRandom.uuid
80
85
  acquired_at = nil
81
- start = monotonic_now
86
+ start = LockBackend.monotonic_now
82
87
 
83
88
  loop do
84
- if try_acquire(store, key, owner, ttl)
85
- acquired_at = monotonic_now
89
+ if LockBackend.try_acquire(store, key, owner, ttl)
90
+ acquired_at = LockBackend.monotonic_now
86
91
  wait_ms = ((acquired_at - start) * 1000).round
87
92
  instrument("acquired", key, wait_ms: wait_ms)
88
93
  break
89
94
  end
90
95
 
91
- elapsed = monotonic_now - start
96
+ elapsed = LockBackend.monotonic_now - start
92
97
  if elapsed >= wait
93
98
  waited_ms = (elapsed * 1000).round
94
99
  instrument("timeout", key, waited_ms: waited_ms)
@@ -96,15 +101,15 @@ module Parse
96
101
  "Could not acquire create-lock for #{parse_class} within #{wait}s"
97
102
  end
98
103
  instrument("contended", key, elapsed_ms: (elapsed * 1000).round) if elapsed > 0
99
- sleep(poll_interval)
104
+ sleep(LockBackend.poll_interval)
100
105
  end
101
106
 
102
107
  begin
103
108
  yield
104
109
  ensure
105
110
  if acquired_at
106
- release(store, key, owner)
107
- held_ms = ((monotonic_now - acquired_at) * 1000).round
111
+ LockBackend.release(store, key, owner)
112
+ held_ms = ((LockBackend.monotonic_now - acquired_at) * 1000).round
108
113
  instrument("released", key, held_ms: held_ms)
109
114
  end
110
115
  end
@@ -123,7 +128,7 @@ module Parse
123
128
  "synchronize key payload exceeds #{MAX_PAYLOAD_BYTES} bytes (got #{payload.bytesize})"
124
129
  end
125
130
 
126
- secret = lock_secret_for(store: lock_store)
131
+ secret = lock_secret_for(store: LockBackend.lock_store)
127
132
  digest = if secret
128
133
  OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
129
134
  else
@@ -134,23 +139,14 @@ module Parse
134
139
 
135
140
  # @!visibility private
136
141
  def reset!
137
- @auto_secret = nil
138
- @plain_sha_warned = nil
139
- @degraded_warned_at = nil
140
- @process_mutex_registry = nil
142
+ # All resettable state lives on Parse::LockBackend now —
143
+ # @degraded_warned_at, @process_mutex_registry, @auto_secret,
144
+ # @plain_sha_warned. v5.1.0 extraction.
145
+ LockBackend.reset!
141
146
  end
142
147
 
143
148
  private
144
149
 
145
- def lock_store
146
- if Parse.respond_to?(:synchronize_create_store) && Parse.synchronize_create_store
147
- return Parse.synchronize_create_store
148
- end
149
- Parse.cache
150
- rescue Parse::Error::ConnectionError
151
- nil
152
- end
153
-
154
150
  def parse_application_id
155
151
  Parse.client.application_id
156
152
  rescue Parse::Error::ConnectionError
@@ -253,121 +249,19 @@ module Parse
253
249
  end
254
250
  end
255
251
 
256
- def degraded_store?(store)
257
- return true if store.nil?
258
- bottom = walk_to_adapter(store)
259
- return true if bottom.nil?
260
- klass_name = bottom.class.name.to_s
261
- klass_name.include?("Memory") || klass_name.include?("Null")
262
- end
263
-
264
- def walk_to_adapter(store)
265
- current = store
266
- # Walk transformer chain to find bottom Moneta adapter
267
- while current.respond_to?(:adapter) && current.adapter && current.adapter != current
268
- current = current.adapter
269
- end
270
- current
271
- end
272
-
273
- def handle_degraded(mode, key)
274
- case mode
275
- when :raise
276
- raise Parse::CreateLockUnavailableError,
277
- "synchronize requires a cross-process cache store (Redis); current store is process-local"
278
- when :proceed
279
- # silent
280
- when :warn_throttled
281
- now = monotonic_now
282
- if @degraded_warned_at.nil? || (now - @degraded_warned_at) >= DEGRADED_WARNING_THROTTLE_SECONDS
283
- @degraded_warned_at = now
284
- warn "[Parse::CreateLock] Lock store is process-local (Moneta Memory/Null). " \
285
- "Cross-process locking is NOT in effect. Configure a Redis-backed cache to enable distributed locking."
286
- end
287
- else # :warn (default)
288
- warn "[Parse::CreateLock] Lock store is process-local; cross-process locking disabled. " \
289
- "key_digest=#{key[KEY_PREFIX.size, 12]}…"
290
- end
291
- end
292
-
293
- def try_acquire(store, key, owner, ttl)
294
- # Trigger lazy TTL sweep on Moneta::Memory before #create (no-op on Redis).
295
- # Without this, Memory adapter returns false on #create even after TTL expiry
296
- # until a #key? or #[] access flushes the stale entry.
297
- store.key?(key)
298
- store.create(key, owner, expires: ttl)
299
- rescue StandardError => e
300
- warn "[Parse::CreateLock] acquire error (#{e.class}): #{e.message}"
301
- false
302
- end
303
-
304
- def release(store, key, owner)
305
- # Best-effort compare-and-delete. Moneta does not expose atomic CAD;
306
- # the worst-case race here is bounded by the short TTL (default #{DEFAULT_TTL}s).
307
- current = store[key]
308
- store.delete(key) if current == owner
309
- rescue StandardError => e
310
- warn "[Parse::CreateLock] release error (#{e.class}): #{e.message}"
311
- end
312
-
313
- def poll_interval
314
- DEFAULT_POLL_BASE + (rand * 2 - 1) * DEFAULT_POLL_JITTER
315
- end
316
-
317
- def monotonic_now
318
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
319
- end
252
+ # All the lock infrastructure — store discovery, degraded
253
+ # detection, atomic-SETNX, release semantics, poll-interval
254
+ # jitter, process-mutex registry, AND HMAC secret resolution —
255
+ # lives on {Parse::LockBackend} now (v5.1.0 extraction). The
256
+ # only CreateLock-specific helper still here is `clamp` for
257
+ # the public API's input range.
320
258
 
321
259
  def clamp(value, lo, hi)
322
260
  [lo, value, hi].sort[1]
323
261
  end
324
262
 
325
- def process_mutex(key)
326
- @process_mutex_registry_lock ||= Mutex.new
327
- @process_mutex_registry_lock.synchronize do
328
- @process_mutex_registry ||= {}
329
- @process_mutex_registry[key] ||= Mutex.new
330
- end
331
- end
332
-
333
263
  def lock_secret_for(store:)
334
- configured = configured_secret
335
- return configured if configured && !configured.empty?
336
-
337
- # No operator-configured secret. Behavior depends on store type:
338
- # - Memory/Null adapter: locking is already process-local, so a
339
- # per-process auto-derived HMAC secret is fine and preserves
340
- # privacy in tests / single-process deployments.
341
- # - Redis (or any cross-process store): a per-process secret would
342
- # break cross-process key equality, defeating the lock. Fall back
343
- # to plain SHA256 with a one-time warning so operators know to
344
- # harden key material.
345
- if degraded_store?(store)
346
- auto_secret
347
- else
348
- warn_plain_sha_once
349
- nil
350
- end
351
- end
352
-
353
- def configured_secret
354
- if Parse.respond_to?(:synchronize_create_secret) && Parse.synchronize_create_secret
355
- return Parse.synchronize_create_secret.to_s
356
- end
357
- ENV["PARSE_STACK_LOCK_SECRET"]
358
- end
359
-
360
- def auto_secret
361
- @auto_secret ||= SecureRandom.hex(32)
362
- end
363
-
364
- def warn_plain_sha_once
365
- return if @plain_sha_warned
366
- @plain_sha_warned = true
367
- warn "[Parse::CreateLock:SECURITY] No PARSE_STACK_LOCK_SECRET configured and Redis-backed store detected. " \
368
- "Falling back to plain SHA256 for lock-key derivation so cross-process locking actually works. " \
369
- "Lock keys are deterministic and may expose query_attrs content via Redis MONITOR/snapshots. " \
370
- "Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying."
264
+ LockBackend.lock_secret_for(store: store, source: "Parse::CreateLock")
371
265
  end
372
266
 
373
267
  def instrument(event, key, payload = {})
@@ -88,18 +88,34 @@ module Parse
88
88
  # field write; the guard is otherwise closed.
89
89
  WRITER_KEY = :parse_embed_managed_writer
90
90
 
91
- # Frozen value-object capturing one `embed` declaration. Stored on
92
- # the owning class under `embed_directives[into]` and passed to
91
+ # Frozen value-object capturing one `embed` or `embed_image`
92
+ # declaration. Stored on the owning class under
93
+ # `embed_directives[into]` and passed to
93
94
  # {EmbedManaged.recompute_embedding!} from the per-class
94
95
  # before_save callback.
96
+ #
97
+ # `modality` is `nil` (treated as `:text`) for {.embed}-declared
98
+ # directives and `:image` for {.embed_image}. The image path
99
+ # routes through `Parse::Embeddings.validate_image_url!` and
100
+ # `Provider#embed_image` rather than `Provider#embed_text`;
101
+ # digest tracking is over the file URL String rather than the
102
+ # concatenated source text.
103
+ #
104
+ # `allow_insecure` is forwarded to {.validate_image_url!} for
105
+ # image directives only; ignored for text.
95
106
  EmbedDirective = Struct.new(
96
107
  :sources, :into, :digest_field, :input_type, :provider_name,
108
+ :modality, :allow_insecure,
97
109
  keyword_init: true,
98
110
  ) do
99
111
  def freeze
100
112
  sources.freeze
101
113
  super
102
114
  end
115
+
116
+ def image?
117
+ modality == :image
118
+ end
103
119
  end
104
120
 
105
121
  # @!visibility private
@@ -186,6 +202,99 @@ module Parse
186
202
  into
187
203
  end
188
204
 
205
+ # Declare a managed image embedding. Mirrors {.embed} but the
206
+ # source field is a `:file` property (Parse::File) and the
207
+ # provider call routes through {Parse::Embeddings::Provider#embed_image}
208
+ # rather than `#embed_text`. v5.1 ships URL-only: the SDK
209
+ # extracts the file's URL, validates it through
210
+ # {Parse::Embeddings.validate_image_url!} (sentinel-gated egress
211
+ # opt-in, CIDR / port / host allowlist), and forwards the
212
+ # canonicalized URL to the provider. The SDK does NOT download
213
+ # image bytes — bytes-fetch is the v5.3 path.
214
+ #
215
+ # **Digest is the URL string, not the file contents.** Replacing
216
+ # the Parse::File with one pointing to a different URL re-embeds;
217
+ # re-saving the same URL is a no-op (zero provider calls).
218
+ # Cloud-stored Parse files have stable URLs unless overwritten,
219
+ # so this is the right cache key for most uploads. If you mutate
220
+ # the underlying bytes at the SAME URL (e.g. PUT-replace on S3
221
+ # without renaming), the embedding will NOT refresh; rename the
222
+ # file or set `:#{into}_digest` to nil and resave to force re-embed.
223
+ #
224
+ # @param source_field [Symbol] one `:file` property whose URL
225
+ # feeds the provider. (v5.1 accepts a single source per
226
+ # directive; multi-image-per-record support is deferred.)
227
+ # @param into [Symbol] the `:vector` property to populate.
228
+ # Must already be declared with `provider:` metadata.
229
+ # @param input_type [Symbol] forwarded to {Provider#embed_image}.
230
+ # Defaults to `:search_document`.
231
+ # @param digest_field [Symbol, nil] override for the URL-digest
232
+ # sibling. Defaults to `:"#{into}_digest"`. Auto-declared as
233
+ # `:string` if not already declared.
234
+ # @param allow_insecure [Boolean] forwarded to
235
+ # {Parse::Embeddings.validate_image_url!}; permit `http://`
236
+ # for local-dev CDN proxies. Default false.
237
+ # @return [Symbol] the target vector field name.
238
+ # @raise [InvalidEmbedDeclaration] on declaration-time misuse.
239
+ def embed_image(source_field, into:, input_type: :search_document,
240
+ digest_field: nil, allow_insecure: false)
241
+ into = into.to_sym
242
+ unless vector_properties.key?(into)
243
+ raise InvalidEmbedDeclaration,
244
+ "#{self}.embed_image: `into: :#{into}` is not a declared :vector property " \
245
+ "(declared :vector fields: #{vector_properties.keys.inspect})."
246
+ end
247
+ provider_name = vector_properties.dig(into, :provider)
248
+ if provider_name.nil?
249
+ raise InvalidEmbedDeclaration,
250
+ "#{self}.embed_image: `into: :#{into}` has no `provider:` declared on its " \
251
+ ":vector property. Add `provider: :voyage` (or another registered name) " \
252
+ "to the property declaration."
253
+ end
254
+
255
+ source = source_field.to_sym
256
+ unless fields.key?(source)
257
+ raise InvalidEmbedDeclaration,
258
+ "#{self}.embed_image: source field #{source.inspect} is not declared on this class."
259
+ end
260
+ unless fields[source] == :file
261
+ raise InvalidEmbedDeclaration,
262
+ "#{self}.embed_image: source field #{source.inspect} must be a :file property " \
263
+ "(got #{fields[source].inspect}). v5.1 image embedding accepts Parse::File " \
264
+ "sources only — text sources go through `embed`."
265
+ end
266
+
267
+ digest_field = (digest_field || :"#{into}_digest").to_sym
268
+ unless fields.key?(digest_field)
269
+ property digest_field, :string
270
+ end
271
+
272
+ directive = EmbedDirective.new(
273
+ sources: [source],
274
+ into: into,
275
+ digest_field: digest_field,
276
+ input_type: input_type,
277
+ provider_name: provider_name,
278
+ modality: :image,
279
+ allow_insecure: allow_insecure,
280
+ ).freeze
281
+ embed_directives[into] = directive
282
+
283
+ callback_method = :"_auto_embed_#{into}!"
284
+ define_method(callback_method) do
285
+ Parse::Core::EmbedManaged.recompute_embedding!(self, directive)
286
+ end
287
+
288
+ already_registered = _save_callbacks.any? do |cb|
289
+ cb.kind == :before && (cb.filter.to_sym rescue cb.filter) == callback_method
290
+ end
291
+ before_save callback_method unless already_registered
292
+
293
+ install_embed_writer_guard!(into, [source])
294
+
295
+ into
296
+ end
297
+
189
298
  # @!visibility private
190
299
  # Prepend a module that intercepts the public `<into>=` setter
191
300
  # and raises {ProtectedFieldError} unless the current thread has
@@ -225,19 +334,19 @@ module Parse
225
334
  end
226
335
 
227
336
  # @!visibility private
228
- # before_save body. Computes the SHA-256 digest of the
229
- # concatenated source-field values. If the digest matches the
230
- # stored sibling AND the target vector is already populated, the
231
- # method returns without contacting the provider. Otherwise it
232
- # calls the provider, validates the response shape, wraps the
233
- # vector, and writes both the vector and digest under the writer
234
- # guard (so the public setters' dirty-tracking fires).
337
+ # before_save body. Dispatches on `directive.modality`: text
338
+ # directives concatenate source-field values and call
339
+ # `Provider#embed_text`; image directives extract the source
340
+ # Parse::File's URL, validate it via
341
+ # `Parse::Embeddings.validate_image_url!`, and call
342
+ # `Provider#embed_image`. Digest tracking elides the provider
343
+ # call when the source has not changed since last save.
235
344
  def self.recompute_embedding!(record, directive)
236
- text = build_source_text(record, directive.sources)
345
+ input = build_source_input(record, directive)
237
346
  stored_digest = record.public_send(directive.digest_field)
238
347
  target_present = !record.public_send(directive.into).nil?
239
348
 
240
- if text.empty?
349
+ if input.nil? || input.empty?
241
350
  if target_present || !stored_digest.nil?
242
351
  with_writer(directive.into) do
243
352
  record.public_send(:"#{directive.into}=", nil)
@@ -247,11 +356,11 @@ module Parse
247
356
  return
248
357
  end
249
358
 
250
- digest = digest_for(text)
359
+ digest = digest_for(input)
251
360
  return if stored_digest == digest && target_present
252
361
 
253
362
  provider = Parse::Embeddings.provider(directive.provider_name)
254
- vectors = provider.embed_text([text], input_type: directive.input_type)
363
+ vectors = call_provider(provider, directive, input)
255
364
  unless vectors.is_a?(Array) && vectors.length == 1 && vectors.first.is_a?(Array)
256
365
  raise Parse::Embeddings::InvalidResponseError,
257
366
  "Parse::Core::EmbedManaged (#{record.class}##{directive.into}): provider " \
@@ -273,6 +382,46 @@ module Parse
273
382
  record.public_send(:"#{directive.digest_field}=", digest)
274
383
  end
275
384
 
385
+ # @!visibility private
386
+ # Build the provider input for `directive`: concatenated text for
387
+ # text directives; the raw image URL for image directives.
388
+ # Returns `nil` (treated as "clear the embedding") when the source
389
+ # is absent, empty, or — for images — has no URL.
390
+ #
391
+ # **Image path does not validate here.** Validation runs once,
392
+ # inside the provider's `embed_image` call. Validating here
393
+ # would double-resolve every URL (round-2 audit LOW #3) since
394
+ # provider implementations call `validate_image_url!` again.
395
+ # The digest is computed from the raw URL string, which is fine
396
+ # — the digest is a content fingerprint, not a security boundary.
397
+ # If validation fails, the provider raises `InvalidImageURL` /
398
+ # `ConfirmationRequired` from inside `recompute_embedding!`, which
399
+ # surfaces from `before_save` exactly as before.
400
+ def self.build_source_input(record, directive)
401
+ if directive.image?
402
+ source_field = directive.sources.first
403
+ file = record.public_send(source_field)
404
+ return nil if file.nil?
405
+ url = file.respond_to?(:url) ? file.url : nil
406
+ return nil if url.nil? || url.to_s.empty?
407
+ url.to_s
408
+ else
409
+ build_source_text(record, directive.sources)
410
+ end
411
+ end
412
+
413
+ # @!visibility private
414
+ # Dispatch the provider call based on directive modality.
415
+ def self.call_provider(provider, directive, input)
416
+ if directive.image?
417
+ provider.embed_image([input],
418
+ input_type: directive.input_type,
419
+ allow_insecure: directive.allow_insecure ? true : false)
420
+ else
421
+ provider.embed_text([input], input_type: directive.input_type)
422
+ end
423
+ end
424
+
276
425
  # @!visibility private
277
426
  # Concatenate source-field string values. `nil` and blank entries
278
427
  # are skipped; remaining values are joined with a double newline.