parse-stack-next 5.7.6 → 5.8.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -21,6 +21,10 @@ module Parse
21
21
  EMPTY_SET = Set.new.freeze
22
22
  private_constant :EMPTY_SET
23
23
 
24
+ # Parse Server's default server-config `protectedFields` option. See
25
+ # {.default_protected_fields}.
26
+ DEFAULT_PROTECTED_FIELDS = { "_User" => { "*" => ["email"].freeze }.freeze }.freeze
27
+
24
28
  # Cache-entry shape. `kind:` is the disposition of the most recent
25
29
  # schema-fetch attempt:
26
30
  #
@@ -82,7 +86,14 @@ module Parse
82
86
  # at request rate.
83
87
  NEGATIVE_TTL = 5
84
88
 
89
+ # How long a schema fetch that failed inside `Parse.without_master_key`
90
+ # is remembered. Kept apart from the shared cache and consulted only
91
+ # inside the block, so it never denies another caller's reads; it only
92
+ # stops a down server from being refetched on every read in the block.
93
+ SUPPRESSED_NEGATIVE_TTL = 2
94
+
85
95
  @cache = {}
96
+ @suppressed_failures = {}
86
97
  @cache_mutex = Mutex.new
87
98
  @cache_ttl = POSITIVE_TTL
88
99
 
@@ -288,31 +299,185 @@ module Parse
288
299
  # `permits?` already refused the query, so this branch is only
289
300
  # reached when callers ask for the protected-fields set directly
290
301
  # (e.g. for documentation or audit tooling).
291
- return EMPTY_SET if entry.kind == :no_clp || entry.kind == :unresolvable
292
- protected_map = entry.clp["protectedFields"] || entry.clp[:protectedFields]
302
+ return EMPTY_SET if entry.kind == :unresolvable
303
+ stored_map = if entry.kind == :no_clp
304
+ nil
305
+ else
306
+ entry.clp["protectedFields"] || entry.clp[:protectedFields]
307
+ end
308
+ protected_map = merge_default_protected_fields(class_name, stored_map)
293
309
  return EMPTY_SET if protected_map.nil? || protected_map.empty?
294
310
 
295
- strip = Set.new(Array(protected_map["*"] || protected_map[:"*"]).map(&:to_s))
296
-
297
311
  claim_set = permission_strings.is_a?(Set) ? permission_strings : permission_strings.to_set
298
- claim_set.each do |claim|
299
- next if claim == "*"
300
- override = protected_map[claim.to_s] || protected_map[claim.to_sym]
301
- next if override.nil?
302
- override_set = Set.new(Array(override).map(&:to_s))
303
- strip &= override_set
304
- end
312
+ protected_sets_for(protected_map, claim_set).reduce { |acc, set| acc & set }&.freeze || EMPTY_SET
313
+ end
305
314
 
306
- strip.freeze
315
+ # Parse Server's server-config `protectedFields` default, merged into
316
+ # every class's CLP `protectedFields` the way Parse Server's
317
+ # `SchemaData` merges them (union per group key).
318
+ #
319
+ # Parse Server ships `{ _User: { "*": ["email"] } }` as the default
320
+ # and does NOT expose the server-config value through
321
+ # `GET /schemas/<Class>`, so the SDK cannot discover it. Without this
322
+ # default a scoped mongo-direct read returned every user's email,
323
+ # and an `email` filter matched, where REST hides the field and
324
+ # refuses the filter. Set this to the same value as the server's
325
+ # `protectedFields` option when it is customized; `{}` disables the
326
+ # merge.
327
+ #
328
+ # @return [Hash{String => Hash{String => Array<String>}}]
329
+ def default_protected_fields
330
+ @default_protected_fields ||= DEFAULT_PROTECTED_FIELDS
331
+ end
332
+
333
+ # @param value [Hash, nil] class name => protectedFields map; nil
334
+ # restores Parse Server's default.
335
+ def default_protected_fields=(value)
336
+ @default_protected_fields = value.nil? ? DEFAULT_PROTECTED_FIELDS : value
307
337
  end
308
338
 
309
- def redact_protected_fields!(documents, strip_set)
339
+ # Strip protected fields from result rows, top-level keys only.
340
+ #
341
+ # Parse Server deletes `object[key]` for each protected key on the
342
+ # returned object and does not descend into nested values, so an
343
+ # `:object` column holding `{ "secret" => ... }` keeps that key even
344
+ # when a top-level `secret` column is protected. Included objects are
345
+ # separate rows of another class; strip them with that class's own set
346
+ # (see {Parse::MongoDB.aggregate}).
347
+ #
348
+ # Parse Server also skips the strip for a `_User` row that IS the
349
+ # requesting user, so a user always sees their own `email`. Pass
350
+ # `class_name:` and `user_id:` to apply that exemption.
351
+ #
352
+ # The exemption reads the row's `_id` (or `objectId`) as it comes out
353
+ # of the pipeline. A caller stage can rewrite that value, so pass
354
+ # `user_id:` only when ownership was settled before any caller stage
355
+ # ran: either no caller stage can change the identity (see
356
+ # {Parse::PipelineSecurity.identity_preserving?}), or the rows went
357
+ # through {.protected_strip_stage} at the head of their pipeline.
358
+ #
359
+ # @param documents [Array<Hash>] rows (Mongo storage or Parse form).
360
+ # @param strip_set [Set<String>] protected field names.
361
+ # @param class_name [String, nil] the rows' class.
362
+ # @param user_id [String, nil] the requesting user's objectId.
363
+ # @return [Array<Hash>] the same array, modified in place.
364
+ def redact_protected_fields!(documents, strip_set, class_name: nil, user_id: nil)
310
365
  return documents if documents.nil? || documents.empty?
311
366
  return documents if strip_set.nil? || strip_set.empty?
312
- documents.each { |doc| walk_and_delete!(doc, strip_set) }
367
+ self_exempt = class_name.to_s == Parse::Model::CLASS_USER && !user_id.to_s.empty?
368
+ documents.each do |doc|
369
+ next unless doc.is_a?(Hash)
370
+ if self_exempt
371
+ row_id = doc["_id"] || doc[:_id] || doc["objectId"] || doc[:objectId]
372
+ next if row_id.to_s == user_id.to_s
373
+ end
374
+ strip_top_level!(doc, strip_set)
375
+ end
313
376
  documents
314
377
  end
315
378
 
379
+ # A pipeline stage that removes protected fields at the database,
380
+ # placed at the head of a (sub-)pipeline before any caller stage runs.
381
+ #
382
+ # Ownership of a `_User` row is decided here, on the stored `_id`, so
383
+ # the self exemption cannot be claimed by a later stage that rewrites
384
+ # `_id`. For a `_User` read by a known user the fields are kept only on
385
+ # that user's own row; everywhere else they are unset. The `_p_<field>`
386
+ # storage column of a protected pointer is removed with the field.
387
+ #
388
+ # @param strip_set [Set<String>] protected field names for the scope.
389
+ # @param class_name [String, nil] the class the stage reads.
390
+ # @param user_id [String, nil] the requesting user's objectId.
391
+ # @return [Hash, nil] a `$unset` or `$set` stage, or nil when nothing
392
+ # is protected.
393
+ def protected_strip_stage(strip_set, class_name: nil, user_id: nil)
394
+ return nil if strip_set.nil? || strip_set.empty?
395
+ columns = strip_set.flat_map { |f| [f.to_s, "_p_#{f}"] }.uniq
396
+ if class_name.to_s == Parse::Model::CLASS_USER && !user_id.to_s.empty?
397
+ own_row = { "$eq" => ["$_id", { "$literal" => user_id.to_s }] }
398
+ { "$set" => columns.to_h { |c| [c, { "$cond" => [own_row, "$#{c}", "$$REMOVE"] }] } }
399
+ else
400
+ { "$unset" => columns }
401
+ end
402
+ end
403
+
404
+ # Evaluate the `op` CLP for a resolved mongo-direct scope with the same
405
+ # mutually exclusive branches Parse Server uses, and return the row
406
+ # constraint the caller must apply.
407
+ #
408
+ # A public, user, or role grant permits every row, even when the CLP
409
+ # also lists `pointerFields` / `readUserFields` (Parse Server only
410
+ # consults those when no other branch grants). When the only grant is
411
+ # a pointer branch, the caller must keep just the rows whose named
412
+ # pointer fields reference the requesting user. `readUserFields`
413
+ # counts as a pointer branch here; the older {permits?} plus
414
+ # {pointer_fields_for} pair ignored it, so a `readUserFields` class
415
+ # let every authenticated user read every row on the direct path.
416
+ #
417
+ # @param class_name [String]
418
+ # @param op [Symbol] one of {OPERATIONS}.
419
+ # @param resolution [Parse::ACLScope::Resolution, nil]
420
+ # @param client [Parse::Client, nil] application whose schema owns the CLP.
421
+ # @param label [String, nil] scope label used in the error message.
422
+ # @return [Array<String>, nil] pointer field names that must reference
423
+ # the requesting user, or nil when every row is permitted. Always nil
424
+ # for master and nil resolutions.
425
+ # @raise [Denied] when the scope cannot perform `op` at all, including
426
+ # when the CLP is unresolvable (fail closed).
427
+ def row_constraint_for!(class_name, op, resolution, client: nil, label: nil)
428
+ return nil if resolution.nil?
429
+ return nil if resolution.respond_to?(:master?) && resolution.master?
430
+ perms = resolution.respond_to?(:permission_strings) ? resolution.permission_strings : nil
431
+ return nil if perms.nil?
432
+
433
+ user_id = resolution.respond_to?(:user_id) ? resolution.user_id.to_s : ""
434
+ evaluation = evaluate_access(
435
+ class_name, op,
436
+ claims: perms,
437
+ authenticated: !user_id.empty?,
438
+ user_id: user_id.empty? ? nil : user_id,
439
+ client: client,
440
+ )
441
+ if evaluation.allowed?
442
+ fields = Array(evaluation.pointer_fields)
443
+ return fields.empty? ? nil : fields
444
+ end
445
+
446
+ warn_unresolvable_once!(class_name) if evaluation.reason == :clp_unresolvable
447
+ scope = label ? "the current #{label} scope" : "the current scope"
448
+ message = if %i[concrete_user_required authentication_required].include?(evaluation.reason) &&
449
+ !pointer_fields_from_entry(class_name, op, client).empty?
450
+ "CLP requires user identity (pointerFields=#{pointer_fields_from_entry(class_name, op, client).inspect}) " \
451
+ "but #{scope} has no user_id."
452
+ else
453
+ "CLP refuses #{op} on '#{class_name}' for #{scope}."
454
+ end
455
+ raise Denied.new(class_name, op, message)
456
+ end
457
+
458
+ # A `$match` predicate keeping only rows whose pointer fields reference
459
+ # `user_id`, in Mongo storage form. A single pointer is stored as
460
+ # `_p_<field>: "_User$<id>"`; an array of pointers is stored under the
461
+ # bare field name as `{ __type, className, objectId }` elements. Both
462
+ # forms are matched, ORed across fields, as Parse Server's
463
+ # `addPointerPermissions` does.
464
+ #
465
+ # @param pointer_fields [Array<String>]
466
+ # @param user_id [String]
467
+ # @return [Hash] a `$match` predicate.
468
+ def pointer_fields_predicate(pointer_fields, user_id)
469
+ uid = user_id.to_s
470
+ storage = "#{Parse::Model::CLASS_USER}$#{uid}"
471
+ clauses = Array(pointer_fields).flat_map do |field|
472
+ f = field.to_s
473
+ [
474
+ { "_p_#{f}" => storage },
475
+ { f => { "$elemMatch" => { "className" => Parse::Model::CLASS_USER, "objectId" => uid } } },
476
+ ]
477
+ end
478
+ { "$or" => clauses }
479
+ end
480
+
316
481
  def filter_by_pointer_fields(documents, pointer_fields, user_id)
317
482
  return documents if pointer_fields.nil? || pointer_fields.empty?
318
483
  return [] if user_id.nil? || user_id.to_s.empty?
@@ -332,7 +497,10 @@ module Parse
332
497
  end
333
498
 
334
499
  def reset_cache!
335
- @cache_mutex.synchronize { @cache.clear }
500
+ @cache_mutex.synchronize do
501
+ @cache.clear
502
+ @suppressed_failures.clear
503
+ end
336
504
  # Also drop the unresolvable-class warned-once registry so
337
505
  # tests that assert on `warn` emission for a class don't get
338
506
  # silenced by an earlier test's call.
@@ -373,6 +541,54 @@ module Parse
373
541
 
374
542
  private
375
543
 
544
+ # Collect one field set per protectedFields group that applies to the
545
+ # caller, mirroring Parse Server's `DatabaseController.addProtectedFields`.
546
+ # Only groups PRESENT in the map participate. A missing `"*"` key does
547
+ # not mean "nothing protected": a map of `{ "role:Restricted" => ["secret"] }`
548
+ # still strips `secret` for a member of `Restricted`. The final strip set
549
+ # is the intersection of the applicable sets, so membership in a group
550
+ # with a narrower (or empty) list relaxes protection.
551
+ #
552
+ # Groups:
553
+ # - `"*"` applies to everyone.
554
+ # - `"authenticated"` applies when the claim set carries a concrete user id.
555
+ # - `"role:<name>"` applies for each role claim.
556
+ # - `"<userId>"` applies for the caller's own user id.
557
+ # - `"userField:<field>"` entries are per-object pointer rules that
558
+ # Parse Server evaluates against each row. They are skipped here,
559
+ # which can only over-protect, never under-protect.
560
+ #
561
+ # Role entries apply even when the claim set has no user id (an
562
+ # `acl_role:` scope). Parse Server has no REST equivalent of a
563
+ # role-only caller, and the SDK treats a granted role claim as held.
564
+ #
565
+ # @return [Array<Set<String>>]
566
+ def protected_sets_for(protected_map, claim_set)
567
+ lookup = lambda do |key|
568
+ value = protected_map[key]
569
+ value = protected_map[key.to_sym] if value.nil?
570
+ value.nil? ? nil : Set.new(Array(value).map(&:to_s))
571
+ end
572
+
573
+ sets = []
574
+ public_set = lookup.call("*")
575
+ sets << public_set if public_set
576
+
577
+ user_claims = claim_set.map(&:to_s).reject { |c| c == "*" || c.start_with?("role:") }
578
+ if user_claims.any?
579
+ auth_set = lookup.call("authenticated")
580
+ sets << auth_set if auth_set
581
+ end
582
+
583
+ claim_set.each do |claim|
584
+ key = claim.to_s
585
+ next if key == "*" || key == "authenticated" || key.start_with?("userField:")
586
+ set = lookup.call(key)
587
+ sets << set if set
588
+ end
589
+ sets
590
+ end
591
+
376
592
  # Always returns a {CacheEntry}. On schema-fetch failure (network
377
593
  # error, unsuccessful response, raised exception, missing client)
378
594
  # the entry has `kind: :unresolvable` and is held for
@@ -393,6 +609,12 @@ module Parse
393
609
  cached = @cache_mutex.synchronize { @cache[key] }
394
610
  return cached if cached && !stale?(cached)
395
611
 
612
+ suppressed = Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
613
+ if suppressed
614
+ failed_at = @cache_mutex.synchronize { @suppressed_failures[key] }
615
+ return unresolvable_entry if failed_at && monotonic_now - failed_at < SUPPRESSED_NEGATIVE_TTL
616
+ end
617
+
396
618
  entry = if resolved_client.nil?
397
619
  # No client configured (Parse.setup never called, etc.) —
398
620
  # treat as unresolvable so we fail closed instead of
@@ -400,7 +622,7 @@ module Parse
400
622
  unresolvable_entry
401
623
  else
402
624
  begin
403
- response = resolved_client.schema(class_key)
625
+ response = fetch_schema_response(resolved_client, class_key)
404
626
  if response&.success?
405
627
  schema = response.result || {}
406
628
  clp = schema["classLevelPermissions"] || {}
@@ -414,10 +636,59 @@ module Parse
414
636
  end
415
637
  end
416
638
 
417
- @cache_mutex.synchronize { @cache[key] = entry }
639
+ # A failure inside `Parse.without_master_key` is not put in the
640
+ # shared cache. The request keeps the master key there, but a custom
641
+ # schema source may not, and a negative entry is shared by every
642
+ # caller of this class for {NEGATIVE_TTL}: one block must not deny
643
+ # every other request's scoped reads. It is remembered briefly in a
644
+ # separate table read only inside the block.
645
+ if entry.kind == :unresolvable && suppressed
646
+ @cache_mutex.synchronize { @suppressed_failures[key] = monotonic_now }
647
+ else
648
+ @cache_mutex.synchronize { @cache[key] = entry }
649
+ end
418
650
  entry
419
651
  end
420
652
 
653
+ # Fetch a class schema with the master key, explicitly.
654
+ #
655
+ # `GET /schemas/<Class>` is master-key-only on Parse Server. A plain
656
+ # `client.schema(name)` call inherits the request layer's auth
657
+ # resolution, so inside `Parse.with_session(token)` (or on a client in
658
+ # `client_mode`) it sends the user's session token instead of the master
659
+ # key. Parse Server refuses that, the entry is recorded as
660
+ # `:unresolvable`, and every scoped mongo-direct read in the block is
661
+ # denied by CLP (and the denial is negatively cached). Passing
662
+ # `use_master_key: true` makes the request layer skip both the ambient
663
+ # and the client-bound session token. A client that holds no master key
664
+ # still cannot read schemas, which stays fail-closed.
665
+ #
666
+ # The schema is metadata, so the request also carries the SDK's
667
+ # metadata marker: it keeps the master key inside
668
+ # `Parse.without_master_key`, which strips it from every other request
669
+ # in the block. Without it the fetch was refused there and the
670
+ # `:unresolvable` entry denied every scoped read of the class, for all
671
+ # callers, until it expired.
672
+ #
673
+ # Objects that only implement `#schema` (test doubles, custom schema
674
+ # sources installed via {.schema_client}), and clients whose `#schema`
675
+ # was overridden on the instance, keep the old call.
676
+ def fetch_schema_response(resolved_client, class_key)
677
+ stock_schema = begin
678
+ resolved_client.method(:schema).owner == Parse::API::Schema
679
+ rescue NameError
680
+ false
681
+ end
682
+ if resolved_client.is_a?(Parse::Client) && stock_schema
683
+ safe = Parse::API::PathSegment.identifier!(class_key, kind: "class name")
684
+ resolved_client.request(:get, "schemas/#{safe}",
685
+ opts: { cache: false, use_master_key: true,
686
+ metadata_master: Parse::Client::METADATA_MASTER_REQUEST })
687
+ else
688
+ resolved_client.schema(class_key)
689
+ end
690
+ end
691
+
421
692
  def unresolvable_entry
422
693
  CacheEntry.new(kind: :unresolvable, clp: nil, fetched_at: monotonic_now)
423
694
  end
@@ -514,15 +785,36 @@ module Parse
514
785
  s != "*" && !s.start_with?("role:")
515
786
  end
516
787
 
517
- def walk_and_delete!(node, strip_set)
518
- case node
519
- when Hash
520
- strip_set.each { |k| node.delete(k) }
521
- node.each_value { |v| walk_and_delete!(v, strip_set) }
522
- when Array
523
- node.each { |v| walk_and_delete!(v, strip_set) }
788
+ # Delete protected keys from one row. Mongo stores a pointer column as
789
+ # `_p_<field>`, so the storage form of a protected pointer is removed
790
+ # along with the Parse-form key.
791
+ def strip_top_level!(doc, strip_set)
792
+ strip_set.each do |k|
793
+ key = k.to_s
794
+ doc.delete(key)
795
+ doc.delete(key.to_sym)
796
+ doc.delete("_p_#{key}")
524
797
  end
525
- node
798
+ doc
799
+ end
800
+
801
+ def merge_default_protected_fields(class_name, stored_map)
802
+ defaults = default_protected_fields
803
+ extra = defaults.is_a?(Hash) ? (defaults[class_name.to_s] || defaults[class_name.to_s.to_sym]) : nil
804
+ return stored_map if extra.nil? || extra.empty?
805
+ merged = {}
806
+ (stored_map || {}).each { |k, v| merged[k.to_s] = Array(v).map(&:to_s) }
807
+ extra.each do |k, v|
808
+ key = k.to_s
809
+ merged[key] = (Array(merged[key]) + Array(v).map(&:to_s)).uniq
810
+ end
811
+ merged
812
+ end
813
+
814
+ def pointer_fields_from_entry(class_name, op, client)
815
+ entry = fetch(class_name, client: client)
816
+ return [] unless entry.kind == :cached_clp
817
+ pointer_fields_from(entry.clp, op)
526
818
  end
527
819
 
528
820
  def any_pointer_matches?(doc, pointer_fields, user_id)
data/lib/parse/console.rb CHANGED
@@ -46,8 +46,8 @@ module Parse
46
46
  # puts "[#{event}] #{obj.title} (#{obj.id})"
47
47
  # end
48
48
  #
49
- # @example Admin-style with master key (no ambient session)
50
- # Parse.with_session(nil) { Parse.watch(JobRun) }
49
+ # @example Outside any session block (master key on a server client)
50
+ # Parse.watch(JobRun)
51
51
  #
52
52
  # @param klass [Class] a Parse::Object subclass.
53
53
  # @param where [Hash] optional query constraints.
@@ -116,7 +116,64 @@ module Parse
116
116
  # {Parse::Middleware::BodyBuilder::REDACTED_HEADERS}.
117
117
  class Voyage < Provider
118
118
  class AuthenticationError < Error; end
119
- class BadRequestError < Error; end
119
+ # A 4xx the provider refused outright. Carries the HTTP status and
120
+ # the provider's own error text (bounded and terminal-sanitized) so
121
+ # callers can tell a request that was merely too large from one that
122
+ # is malformed.
123
+ class BadRequestError < Error
124
+ # Phrases that positively identify a request rejected for its
125
+ # SIZE (too many inputs or too many tokens summed across the
126
+ # batch). Matched case-insensitively against the provider's error
127
+ # text. Kept deliberately narrow: an unrelated 400 (bad parameter,
128
+ # invalid JSON) must never be mistaken for a size error, because
129
+ # the response to a size error is to split and resend.
130
+ #
131
+ # Voyage documents these 400 causes as "Batch size is too large"
132
+ # and "Total number of tokens in the batch exceeds the limit"; its
133
+ # messages read like "The max allowed tokens per submitted batch
134
+ # is 120000" and "The batch size limit is 128".
135
+ REQUEST_TOO_LARGE_PATTERNS = [
136
+ /batch size is too large/i,
137
+ /batch size limit/i,
138
+ /total number of tokens in the batch exceeds/i,
139
+ /max(?:imum)? allowed tokens per (?:submitted )?batch/i,
140
+ ].freeze
141
+
142
+ # Phrases identifying ONE input that is longer than the model's
143
+ # context window ("Number of tokens in an example exceeds the
144
+ # context length"). Splitting the batch cannot fix this.
145
+ INPUT_TOO_LONG_PATTERNS = [
146
+ /tokens in an example exceeds the context length/i,
147
+ ].freeze
148
+
149
+ # @return [Integer, nil] the HTTP status the provider returned.
150
+ attr_reader :status
151
+ # @return [String, nil] the provider's error text, truncated and
152
+ # sanitized for terminals and logs.
153
+ attr_reader :detail
154
+
155
+ def initialize(message = nil, status: nil, detail: nil)
156
+ super(message)
157
+ @status = status
158
+ @detail = detail
159
+ end
160
+
161
+ # @return [Boolean] true when the provider rejected the request
162
+ # because the batch (input count or summed tokens) is too large,
163
+ # so resending it in smaller pieces can succeed.
164
+ def request_too_large?
165
+ return true if @status == 413
166
+ return false if @detail.nil? || input_too_long?
167
+ REQUEST_TOO_LARGE_PATTERNS.any? { |re| re.match?(@detail) }
168
+ end
169
+
170
+ # @return [Boolean] true when a single input exceeds the model's
171
+ # context length.
172
+ def input_too_long?
173
+ return false if @detail.nil?
174
+ INPUT_TOO_LONG_PATTERNS.any? { |re| re.match?(@detail) }
175
+ end
176
+ end
120
177
  class RateLimitError < Error; end
121
178
  class TransientError < Error; end
122
179
 
@@ -266,10 +323,31 @@ module Parse
266
323
  # at most this many documents, and this many chunks summed
267
324
  # across them.
268
325
  # The endpoint also caps a request at 120k tokens summed across
269
- # every document, which the SDK cannot check without a tokenizer.
326
+ # every document ({CONTEXT_MAX_REQUEST_TOKENS}), which the SDK can
327
+ # only estimate: it has no tokenizer.
270
328
  MAX_CONTEXT_DOCUMENTS = 1_000
271
329
  MAX_CONTEXT_CHUNKS = 16_000
272
330
 
331
+ # Voyage's cap on input tokens summed across every document in one
332
+ # contextualized request.
333
+ CONTEXT_MAX_REQUEST_TOKENS = 120_000
334
+
335
+ # Bytes per token assumed when ESTIMATING a request's input tokens
336
+ # for packing. Real tokenizers average closer to four bytes per token
337
+ # for English text, so dividing by three over-estimates and packs
338
+ # conservatively. This is an estimate, not a token count: the SDK has
339
+ # no tokenizer. When the estimate still lets an over-limit request
340
+ # through, the provider's size rejection is handled by splitting the
341
+ # request (see {CONTEXT_MAX_SPLIT_DEPTH}).
342
+ CONTEXT_ESTIMATED_BYTES_PER_TOKEN = 3
343
+
344
+ # How many times one contextualized request may be halved after the
345
+ # provider rejects it as too large. Ten halvings take the largest
346
+ # allowed request ({MAX_CONTEXT_DOCUMENTS} documents) down to single
347
+ # documents, so this bounds the retries without stopping short of a
348
+ # one-document request.
349
+ CONTEXT_MAX_SPLIT_DEPTH = 10
350
+
273
351
  # Default `embed_batch_size` for {CONTEXTUALIZED_MODELS}. Each
274
352
  # string is a whole document there, so 128 paragraph-sized inputs
275
353
  # would overrun the 120k-token request cap; 32 leaves room for
@@ -704,25 +782,83 @@ module Parse
704
782
  # Embed documents through the contextualized endpoint and return one
705
783
  # Array of chunk vectors per document, in input order.
706
784
  #
707
- # Voyage accepts up to {MAX_CONTEXT_CHUNKS} chunks per request, but
708
- # that many vectors serialize to far more than {MAX_RESPONSE_BYTES}.
709
- # Whole documents are therefore grouped so each response's estimated
710
- # size stays within the cap, and the groups are sent in turn. A
711
- # single document too large for the cap on its own is sent alone
712
- # with a response allowance sized to its chunk count.
785
+ # This is robust adaptation, not exact token counting. Whole
786
+ # documents are packed into requests that stay within every limit
787
+ # the SDK can check or estimate:
788
+ #
789
+ # * at most {MAX_CONTEXT_DOCUMENTS} documents;
790
+ # * a chunk count whose response stays within {MAX_RESPONSE_BYTES}
791
+ # (sized by the vectors coming back);
792
+ # * an ESTIMATED input-token total within
793
+ # {CONTEXT_MAX_REQUEST_TOKENS} (see
794
+ # {CONTEXT_ESTIMATED_BYTES_PER_TOKEN}).
795
+ #
796
+ # A document is never split across requests. A document that alone
797
+ # exceeds a budget is sent on its own (its response allowance is
798
+ # sized to its chunk count). If the provider still rejects a request
799
+ # as too large, that request is halved by document and each half
800
+ # resent (see {#embed_contextualized_group}), so a mis-estimate costs
801
+ # extra requests rather than failing the batch.
713
802
  def embed_contextualized(documents, input_type, wire_input_type)
714
- per_vector = response_bytes_per_vector
715
- chunks_per_request = [MAX_RESPONSE_BYTES / per_vector, 1].max
803
+ chunks_per_request = [MAX_RESPONSE_BYTES / response_bytes_per_vector, 1].max
716
804
  groups = []
717
- documents.each do |doc|
805
+ documents.each_with_index do |doc, i|
718
806
  last = groups.last
719
- if last && last.sum(&:length) + doc.length <= chunks_per_request
720
- last << doc
807
+ tokens = estimate_input_tokens(doc)
808
+ if last &&
809
+ last[:docs].length < MAX_CONTEXT_DOCUMENTS &&
810
+ last[:chunks] + doc.length <= chunks_per_request &&
811
+ last[:tokens] + tokens <= CONTEXT_MAX_REQUEST_TOKENS
812
+ last[:docs] << doc
813
+ last[:chunks] += doc.length
814
+ last[:tokens] += tokens
721
815
  else
722
- groups << [doc]
816
+ groups << { docs: [doc], offset: i, chunks: doc.length, tokens: tokens }
723
817
  end
724
818
  end
725
- groups.flat_map { |group| embed_contextualized_request(group, input_type, wire_input_type) }
819
+ groups.flat_map do |group|
820
+ embed_contextualized_group(group[:docs], group[:offset], input_type, wire_input_type, 0)
821
+ end
822
+ end
823
+
824
+ # Send one packed group, halving it by document and resending each
825
+ # half when the provider rejects it as too large. Only an error that
826
+ # {BadRequestError#request_too_large?} positively identifies is split;
827
+ # any other error propagates unchanged. Results are concatenated in
828
+ # input order, so alignment with the caller's documents is preserved
829
+ # however the group was divided.
830
+ #
831
+ # @param offset [Integer] index of `documents.first` in the caller's
832
+ # original input, for error messages.
833
+ # @param depth [Integer] halvings so far, bounded by
834
+ # {CONTEXT_MAX_SPLIT_DEPTH}.
835
+ def embed_contextualized_group(documents, offset, input_type, wire_input_type, depth)
836
+ embed_contextualized_request(documents, input_type, wire_input_type)
837
+ rescue BadRequestError => e
838
+ raise unless e.request_too_large?
839
+
840
+ if documents.length == 1
841
+ raise BadRequestError.new(
842
+ "Parse::Embeddings::Voyage: document #{offset} (#{documents.first.length} chunk(s), " \
843
+ "about #{estimate_input_tokens(documents.first)} estimated tokens) is too large for a " \
844
+ "single #{@model} request even on its own. Split that document into fewer or shorter " \
845
+ "chunks, or into several documents. Provider said: #{e.detail || e.message}",
846
+ status: e.status, detail: e.detail,
847
+ )
848
+ end
849
+ raise if depth >= CONTEXT_MAX_SPLIT_DEPTH
850
+
851
+ mid = documents.length / 2
852
+ embed_contextualized_group(documents[0...mid], offset, input_type, wire_input_type, depth + 1) +
853
+ embed_contextualized_group(documents[mid..], offset + mid, input_type, wire_input_type, depth + 1)
854
+ end
855
+
856
+ # Conservative ESTIMATE of a document's input tokens: its chunks'
857
+ # bytes divided by {CONTEXT_ESTIMATED_BYTES_PER_TOKEN}, rounded up.
858
+ # Used only for packing, never to refuse a document.
859
+ def estimate_input_tokens(chunks)
860
+ bytes = chunks.sum(&:bytesize)
861
+ (bytes + CONTEXT_ESTIMATED_BYTES_PER_TOKEN - 1) / CONTEXT_ESTIMATED_BYTES_PER_TOKEN
726
862
  end
727
863
 
728
864
  # @return [Integer] the planning estimate for one vector's JSON size.
@@ -1037,11 +1173,39 @@ module Parse
1037
1173
  sleep(backoff_seconds(attempts))
1038
1174
  next
1039
1175
  end
1040
- raise BadRequestError,
1041
- "Parse::Embeddings::Voyage: #{status} from POST /#{path}."
1176
+ detail = provider_error_detail(response.body)
1177
+ message = +"Parse::Embeddings::Voyage: #{status} from POST /#{path}."
1178
+ message << " #{detail}" if detail
1179
+ raise BadRequestError.new(message, status: status, detail: detail)
1042
1180
  end
1043
1181
  end
1044
1182
 
1183
+ # Longest provider error text kept on a {BadRequestError}.
1184
+ MAX_ERROR_DETAIL_CHARS = 500
1185
+
1186
+ # Extract the provider's error text from a non-2xx body, bounded and
1187
+ # made safe to print. Voyage returns `{ "detail": "..." }`; a
1188
+ # `message`, a string `error`, or a nested `error.message` is accepted
1189
+ # too. Returns nil for an empty, oversized, or unparseable body rather
1190
+ # than raising, since the status code alone still describes the
1191
+ # failure.
1192
+ def provider_error_detail(body)
1193
+ s = body.to_s
1194
+ return nil if s.empty? || s.bytesize > 64 * 1024
1195
+ parsed = JSON.parse(s, max_nesting: 8)
1196
+ return nil unless parsed.is_a?(Hash)
1197
+ text = parsed["detail"] || parsed["message"]
1198
+ err = parsed["error"]
1199
+ text ||= err["message"] if err.is_a?(Hash)
1200
+ text ||= err if err.is_a?(String)
1201
+ text = text.to_json unless text.nil? || text.is_a?(String)
1202
+ return nil if text.nil? || text.strip.empty?
1203
+ text = text[0, MAX_ERROR_DETAIL_CHARS]
1204
+ defined?(Parse::TerminalSafe) ? Parse::TerminalSafe.sanitize_line(text) : text
1205
+ rescue JSON::ParserError
1206
+ nil
1207
+ end
1208
+
1045
1209
  def parse_json_body!(body, max_bytes = MAX_RESPONSE_BYTES)
1046
1210
  s = body.to_s
1047
1211
  if s.bytesize > max_bytes