parse-stack-next 5.0.1 → 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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +792 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/classes/audience.rb +5 -0
  37. data/lib/parse/model/classes/installation.rb +122 -0
  38. data/lib/parse/model/classes/job_schedule.rb +3 -1
  39. data/lib/parse/model/classes/job_status.rb +4 -1
  40. data/lib/parse/model/classes/push_status.rb +4 -1
  41. data/lib/parse/model/classes/session.rb +7 -0
  42. data/lib/parse/model/classes/user.rb +204 -0
  43. data/lib/parse/model/core/create_lock.rb +28 -146
  44. data/lib/parse/model/core/embed_managed.rb +162 -13
  45. data/lib/parse/model/core/parse_reference.rb +17 -1
  46. data/lib/parse/model/core/querying.rb +26 -2
  47. data/lib/parse/model/file.rb +523 -18
  48. data/lib/parse/query.rb +31 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +98 -1
  51. data/parse-stack-next.gemspec +2 -2
  52. metadata +17 -7
@@ -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.
@@ -224,7 +224,23 @@ module Parse
224
224
  if respond_to?(:protect_fields) && respond_to?(:class_permissions)
225
225
  existing = class_permissions.protected_fields_for("*") rescue []
226
226
  merged = (existing + [field_name.to_s]).uniq
227
- protect_fields("*", merged)
227
+ # Suppress Parse::User's "raw protect_fields called" advisory
228
+ # for this internal auto-install. The advisory exists to nudge
229
+ # app code toward the new master_only_fields/self_visible_fields
230
+ # DSL; the parse_reference auto-install is a different concern
231
+ # and should not trip it at gem boot.
232
+ prior = nil
233
+ if is_a?(Class) && self <= Parse::User
234
+ prior = instance_variable_get(:@_user_field_dsl_active)
235
+ instance_variable_set(:@_user_field_dsl_active, true)
236
+ end
237
+ begin
238
+ protect_fields("*", merged)
239
+ ensure
240
+ if is_a?(Class) && self <= Parse::User
241
+ instance_variable_set(:@_user_field_dsl_active, prior)
242
+ end
243
+ end
228
244
  end
229
245
 
230
246
  # Auto-install write-side protection: once the after_create
@@ -435,10 +435,28 @@ module Parse
435
435
  # @param fields [Array<String>] specific fields to watch for changes (nil = all fields)
436
436
  # @param session_token [String] session token for ACL-aware subscriptions
437
437
  # @param client [Parse::LiveQuery::Client] custom LiveQuery client (optional)
438
+ # @param use_master_key [Boolean] per-subscription master-key opt-in.
439
+ # See {Parse::Query#subscribe} for the full description.
440
+ # @yield [subscription] runs the block with the freshly-constructed
441
+ # {Parse::LiveQuery::Subscription} BEFORE the subscribe frame is
442
+ # sent so caller-registered callbacks are wired before any server
443
+ # events can arrive. Optional — callers may still capture the
444
+ # returned subscription and register callbacks later.
445
+ # @example block form (ergonomic, no race window)
446
+ # Post.subscribe(where: { published: true }) do |sub|
447
+ # sub.on(:create) { |obj| puts "new: #{obj.id}" }
448
+ # sub.on(:update) { |obj, prev| puts "updated: #{obj.id}" }
449
+ # end
450
+ # @example capture-then-wire form (equivalent, but has a tiny
451
+ # window between subscribe-frame send and the first .on call
452
+ # where a server event would be dropped if it arrived first)
453
+ # sub = Post.subscribe(where: { published: true })
454
+ # sub.on(:create) { |obj| … }
438
455
  # @return [Parse::LiveQuery::Subscription] the subscription object
439
456
  # @see Parse::LiveQuery::Subscription
440
457
  # @see Parse::Query#subscribe
441
- def subscribe(where: {}, fields: nil, session_token: nil, client: nil)
458
+ def subscribe(where: {}, fields: nil, session_token: nil, client: nil,
459
+ use_master_key: false, &block)
442
460
  # Fall through to the ambient set by `Parse.with_session` / `Parse.login`
443
461
  # so a caller wrapping a region with `with_session(user) { Klass.subscribe ... }`
444
462
  # gets an ACL-aware subscription without re-threading the token.
@@ -446,7 +464,13 @@ module Parse
446
464
  ambient = Parse.current_session_token
447
465
  session_token = ambient if ambient.is_a?(String) && !ambient.empty?
448
466
  end
449
- query(where).subscribe(fields: fields, session_token: session_token, client: client)
467
+ query(where).subscribe(
468
+ fields: fields,
469
+ session_token: session_token,
470
+ client: client,
471
+ use_master_key: use_master_key,
472
+ &block
473
+ )
450
474
  end
451
475
 
452
476
  # Find objects for a given objectId in this collection. The result is a list