parse-stack-next 5.5.6 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +634 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +17 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/binding_audit.rb +211 -0
  54. data/lib/parse/embeddings/cache.rb +17 -17
  55. data/lib/parse/embeddings/cohere.rb +21 -22
  56. data/lib/parse/embeddings/image_fetch.rb +22 -21
  57. data/lib/parse/embeddings/jina.rb +21 -21
  58. data/lib/parse/embeddings/local_http.rb +6 -7
  59. data/lib/parse/embeddings/media_file.rb +137 -0
  60. data/lib/parse/embeddings/openai.rb +5 -5
  61. data/lib/parse/embeddings/provider.rb +40 -1
  62. data/lib/parse/embeddings/qwen.rb +10 -10
  63. data/lib/parse/embeddings/spend_cap.rb +2 -3
  64. data/lib/parse/embeddings/streaming_body.rb +170 -0
  65. data/lib/parse/embeddings/video_source.rb +121 -0
  66. data/lib/parse/embeddings/voyage.rb +554 -143
  67. data/lib/parse/embeddings.rb +83 -27
  68. data/lib/parse/graphql/type_generator.rb +1 -1
  69. data/lib/parse/graphql.rb +5 -5
  70. data/lib/parse/live_query/client.rb +1 -1
  71. data/lib/parse/live_query.rb +1 -1
  72. data/lib/parse/lock.rb +15 -18
  73. data/lib/parse/lock_backend.rb +1 -1
  74. data/lib/parse/lookup_rewriter.rb +6 -6
  75. data/lib/parse/model/classes/role.rb +227 -28
  76. data/lib/parse/model/classes/user.rb +60 -1
  77. data/lib/parse/model/clp.rb +4 -4
  78. data/lib/parse/model/core/actions.rb +97 -15
  79. data/lib/parse/model/core/create_lock.rb +0 -2
  80. data/lib/parse/model/core/describe.rb +57 -57
  81. data/lib/parse/model/core/embed_managed.rb +43 -19
  82. data/lib/parse/model/core/fetching.rb +2 -0
  83. data/lib/parse/model/core/indexing.rb +14 -14
  84. data/lib/parse/model/core/parse_reference.rb +1 -1
  85. data/lib/parse/model/core/properties.rb +39 -3
  86. data/lib/parse/model/core/querying.rb +1 -1
  87. data/lib/parse/model/core/schema.rb +2 -2
  88. data/lib/parse/model/core/search_indexing.rb +2 -2
  89. data/lib/parse/model/core/vector_searchable.rb +34 -8
  90. data/lib/parse/model/file.rb +21 -15
  91. data/lib/parse/model/geojson.rb +2 -2
  92. data/lib/parse/model/geopoint.rb +1 -0
  93. data/lib/parse/model/object.rb +132 -66
  94. data/lib/parse/model/pointer.rb +2 -0
  95. data/lib/parse/model/polygon.rb +3 -6
  96. data/lib/parse/model/push.rb +2 -2
  97. data/lib/parse/model/vector.rb +3 -1
  98. data/lib/parse/mongodb.rb +512 -282
  99. data/lib/parse/pipeline_security.rb +3 -0
  100. data/lib/parse/query/constraints.rb +46 -46
  101. data/lib/parse/query.rb +146 -59
  102. data/lib/parse/retrieval/agent_tool.rb +34 -20
  103. data/lib/parse/retrieval/chunk.rb +1 -0
  104. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  105. data/lib/parse/retrieval/reranker.rb +3 -4
  106. data/lib/parse/retrieval/retriever.rb +4 -6
  107. data/lib/parse/schema/index_migrator.rb +26 -26
  108. data/lib/parse/schema/search_index_migrator.rb +19 -19
  109. data/lib/parse/stack/tasks.rb +6 -6
  110. data/lib/parse/stack/version.rb +1 -1
  111. data/lib/parse/stack.rb +10 -10
  112. data/lib/parse/vector_search/hybrid.rb +166 -33
  113. data/lib/parse/vector_search.rb +141 -13
  114. data/lib/parse/webhooks/payload.rb +1 -0
  115. data/lib/parse/webhooks/registration.rb +4 -4
  116. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  117. data/lib/parse/webhooks.rb +22 -4
  118. data/parse-stack-next.gemspec +6 -6
  119. metadata +14 -1
@@ -65,6 +65,7 @@ module Parse
65
65
  class IndexDriftError < StandardError
66
66
  # @return [Array<String>] human-readable drift findings.
67
67
  attr_reader :findings
68
+
68
69
  def initialize(message, findings: [])
69
70
  @findings = findings
70
71
  super(message)
@@ -149,6 +150,12 @@ module Parse
149
150
  # (fields must be declared `type: "filter"` in the index).
150
151
  # @param index [String, nil] explicit vectorSearch index name.
151
152
  # Skips auto-discovery when given.
153
+ # @param candidate_limit [Integer, nil] rows Atlas returns before
154
+ # ACL / filter / pointer-field enforcement runs; the result is
155
+ # trimmed to `k` afterwards. Defaults to a multiple of `k` for
156
+ # any caller subject to enforcement, so heavy ACL attrition
157
+ # does not silently return fewer than `k` rows. Raise it when
158
+ # a principal can read only a small fraction of the class.
152
159
  # @param num_candidates [Integer, nil] HNSW search width.
153
160
  # @param max_time_ms [Integer, nil] server-side timeout.
154
161
  # @param raw [Boolean] when true return the raw Mongo documents
@@ -192,7 +199,8 @@ module Parse
192
199
  # deadline on the embed step.
193
200
  def find_similar(vector: nil, text: nil, k: 10, field: nil, filter: nil,
194
201
  vector_filter: nil, index: nil,
195
- num_candidates: nil, max_time_ms: nil, raw: false,
202
+ num_candidates: nil, candidate_limit: nil,
203
+ max_time_ms: nil, raw: false,
196
204
  **scope_opts)
197
205
  if vector.nil? && text.nil?
198
206
  raise ArgumentError,
@@ -206,8 +214,7 @@ module Parse
206
214
  resolved_field = resolve_vector_field!(field)
207
215
  declared_dims = vector_properties.dig(resolved_field, :dimensions)
208
216
 
209
- query_vector =
210
- if text.nil?
217
+ query_vector = if text.nil?
211
218
  coerce_query_vector(vector)
212
219
  else
213
220
  embed_query_text!(text, resolved_field)
@@ -222,6 +229,7 @@ module Parse
222
229
  query_vector: query_vector,
223
230
  k: k,
224
231
  num_candidates: num_candidates,
232
+ candidate_limit: candidate_limit,
225
233
  filter: filter,
226
234
  vector_filter: vector_filter,
227
235
  index: index_name,
@@ -284,8 +292,7 @@ module Parse
284
292
  declared_dims = vector_properties.dig(field_sym, :dimensions)
285
293
 
286
294
  qv = query_vector || vec[:query_vector]
287
- qv =
288
- if qv.nil?
295
+ qv = if qv.nil?
289
296
  unless text.is_a?(String) && !text.strip.empty?
290
297
  raise ArgumentError,
291
298
  "#{self}.hybrid_search: pass `text:` (to embed) or a `query_vector:`."
@@ -314,6 +321,7 @@ module Parse
314
321
  query_vector: qv, field: field_sym, index: vector_index,
315
322
  num_candidates: vec[:num_candidates], filter: vec[:filter],
316
323
  vector_filter: vec[:vector_filter],
324
+ candidate_limit: vec[:candidate_limit],
317
325
  },
318
326
  k: k,
319
327
  fusion: fusion,
@@ -327,9 +335,22 @@ module Parse
327
335
  private
328
336
 
329
337
  def resolve_vector_field!(field)
330
- declared = vector_properties.keys
338
+ # A property declared `searchable: false` is storage-only. That
339
+ # may be because it exceeds the Atlas index cap, or simply
340
+ # because the author opted it out. Either way, including it in
341
+ # resolution would let a caller reach a field no index serves,
342
+ # so it is excluded from auto-resolution entirely and refused
343
+ # with an explanation when named outright.
344
+ all_declared = vector_properties.keys
345
+ declared = all_declared.reject { |f| vector_properties[f][:searchable] == false }
346
+
331
347
  if field
332
348
  sym = field.to_sym
349
+ if all_declared.include?(sym) && !declared.include?(sym)
350
+ raise NoVectorProperty,
351
+ "#{self}.find_similar: field :#{sym} is declared `searchable: false` " \
352
+ "(storage-only, not eligible for search) and cannot be searched."
353
+ end
333
354
  unless declared.include?(sym)
334
355
  raise NoVectorProperty,
335
356
  "#{self}.find_similar: field :#{sym} is not a :vector property " \
@@ -342,7 +363,7 @@ module Parse
342
363
  end
343
364
  if declared.empty?
344
365
  raise NoVectorProperty,
345
- "#{self}.find_similar: no :vector property declared on this class."
366
+ "#{self}.find_similar: no searchable :vector property declared on this class."
346
367
  end
347
368
  raise AmbiguousVectorField,
348
369
  "#{self}.find_similar: class declares multiple :vector properties " \
@@ -383,6 +404,11 @@ module Parse
383
404
  "on the property, or pass an explicit `vector:`."
384
405
  end
385
406
  provider = Parse::Embeddings.provider(provider_name)
407
+ # A query embedded by a different model than the stored vectors
408
+ # returns plausible-looking nonsense rather than an error, so
409
+ # the binding is verified here too — before the spend cap is
410
+ # charged and before any provider round-trip.
411
+ Parse::Embeddings::BindingAudit.verify!(self, resolved_field, provider)
386
412
  # Spend cap: every query-embed path (find_similar(text:),
387
413
  # hybrid_search(text:), Retrieval.retrieve) funnels through this
388
414
  # method, so charging here closes the "direct callers bypass the
@@ -405,7 +431,7 @@ module Parse
405
431
  def coerce_query_vector(vector)
406
432
  case vector
407
433
  when Parse::Vector then vector.to_a
408
- when Array then vector
434
+ when Array then vector
409
435
  else
410
436
  raise Parse::VectorSearch::InvalidQueryVector,
411
437
  "vector: must be an Array<Float> or Parse::Vector (got #{vector.class})."
@@ -43,7 +43,6 @@ module Parse
43
43
  # in logs or a CDN access trail.
44
44
  class SignedUrlError < Parse::Error; end
45
45
 
46
-
47
46
  # Regular expression that matches the old legacy Parse hosted file name
48
47
  LEGACY_FILE_RX = /^tfss-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-/
49
48
  # The default attributes in a Parse File hash. Matches the Parse
@@ -86,7 +85,7 @@ module Parse
86
85
  # Alibaba Cloud metadata service (public-IP-space but well-known
87
86
  # cloud-metadata endpoint that must not be reachable from SDK fetches).
88
87
  "100.100.100.200/32",
89
- "::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8", "::ffff:0:0/96"
88
+ "::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8", "::ffff:0:0/96",
90
89
  ].map { |c| IPAddr.new(c) }.freeze
91
90
  # Restrictive port allowlist for Parse::File URL fetches. By default
92
91
  # only the standard HTTP/HTTPS ports are permitted. Operators may
@@ -166,12 +165,14 @@ module Parse
166
165
  # @return [Integer] Maximum byte size for a remote URL fetch via
167
166
  # `Parse::File.create` / `Parse::File.new(url)`.
168
167
  attr_writer :max_remote_size
168
+
169
169
  def max_remote_size
170
170
  @max_remote_size ||= DEFAULT_MAX_REMOTE_SIZE
171
171
  end
172
172
 
173
173
  # @return [Integer] Read/open timeout (seconds) for remote URL fetches.
174
174
  attr_writer :remote_timeout
175
+
175
176
  def remote_timeout
176
177
  @remote_timeout ||= DEFAULT_REMOTE_TIMEOUT
177
178
  end
@@ -182,6 +183,7 @@ module Parse
182
183
  # ".example.com" matches "files.example.com"). Default: empty (any
183
184
  # public host is allowed; private hosts are always denied).
184
185
  attr_writer :allowed_remote_hosts
186
+
185
187
  def allowed_remote_hosts
186
188
  @allowed_remote_hosts ||= []
187
189
  end
@@ -202,6 +204,7 @@ module Parse
202
204
  # entries via leading "." (e.g. `".cdn.example.com"`) match any
203
205
  # subdomain.
204
206
  attr_writer :trusted_url_hosts
207
+
205
208
  def trusted_url_hosts
206
209
  @trusted_url_hosts ||= ["files.parsetfss.com"]
207
210
  end
@@ -226,6 +229,7 @@ module Parse
226
229
  # hydration `attributes=` — asymmetric writer behavior was an
227
230
  # explicit anti-goal of the design.
228
231
  attr_writer :signed_url_policy
232
+
229
233
  def signed_url_policy
230
234
  @signed_url_policy ||= :strip
231
235
  end
@@ -244,12 +248,14 @@ module Parse
244
248
  # The default is intentionally non-breaking; integrators ready to
245
249
  # enforce flip the policy explicitly.
246
250
  attr_writer :untrusted_url_policy
251
+
247
252
  def untrusted_url_policy
248
253
  @untrusted_url_policy ||= :warn
249
254
  end
250
255
 
251
256
  # @return [Array<Integer>] Allowed remote ports for URL fetches.
252
257
  attr_writer :allowed_remote_ports
258
+
253
259
  def allowed_remote_ports
254
260
  @allowed_remote_ports ||= DEFAULT_ALLOWED_REMOTE_PORTS.dup
255
261
  end
@@ -543,10 +549,10 @@ module Parse
543
549
  # "exceeds". Fail fast with a clear message before any DNS/host work.
544
550
  max_bytes = coerce_positive_max_bytes(max_bytes)
545
551
  uri = begin
546
- URI.parse(url_string)
547
- rescue URI::InvalidURIError => e
548
- raise ArgumentError, "Invalid URL: #{e.message}"
549
- end
552
+ URI.parse(url_string)
553
+ rescue URI::InvalidURIError => e
554
+ raise ArgumentError, "Invalid URL: #{e.message}"
555
+ end
550
556
  unless %w[http https].include?(uri.scheme)
551
557
  raise ArgumentError, "Parse::File only supports http(s) URLs (got #{uri.scheme.inspect})"
552
558
  end
@@ -603,8 +609,7 @@ module Parse
603
609
  # @return [Integer, nil]
604
610
  def coerce_positive_max_bytes(max_bytes)
605
611
  return nil if max_bytes.nil?
606
- requested =
607
- begin
612
+ requested = begin
608
613
  Integer(max_bytes)
609
614
  rescue ArgumentError, TypeError
610
615
  raise ArgumentError, "max_bytes must be a positive integer (got #{max_bytes.inspect})"
@@ -931,6 +936,7 @@ module Parse
931
936
  @url = Parse::File.sanitize_hydrated_url(value, fallback: @url, name: @name)
932
937
  end
933
938
  end
939
+
934
940
  private :normalize_and_store_url
935
941
 
936
942
  # @!visibility private
@@ -953,10 +959,10 @@ module Parse
953
959
  return raw unless raw.start_with?("http://") || raw.start_with?("https://")
954
960
 
955
961
  uri = begin
956
- URI.parse(raw)
957
- rescue URI::InvalidURIError
958
- return raw # malformed URL — leave it alone; downstream code already handles
959
- end
962
+ URI.parse(raw)
963
+ rescue URI::InvalidURIError
964
+ return raw # malformed URL — leave it alone; downstream code already handles
965
+ end
960
966
  host = uri.host.to_s.downcase
961
967
  return raw if host.empty?
962
968
 
@@ -975,7 +981,7 @@ module Parse
975
981
  when :strip
976
982
  warn_untrusted_url_host_once(host, action: "stripped")
977
983
  fallback
978
- else # :warn (default)
984
+ else # :warn (default)
979
985
  warn_untrusted_url_host_once(host, action: "accepted")
980
986
  raw
981
987
  end
@@ -1034,8 +1040,8 @@ module Parse
1034
1040
  def save(session_token: nil, use_master_key: nil)
1035
1041
  unless saved? || @contents.nil? || @name.nil?
1036
1042
  opts = {}
1037
- opts[:session_token] = session_token unless session_token.nil?
1038
- opts[:use_master_key] = use_master_key unless use_master_key.nil?
1043
+ opts[:session_token] = session_token unless session_token.nil?
1044
+ opts[:use_master_key] = use_master_key unless use_master_key.nil?
1039
1045
  response = client.create_file(@name, @contents, @mime_type, **opts)
1040
1046
  unless response.error?
1041
1047
  result = response.result
@@ -49,8 +49,7 @@ module Parse
49
49
  end
50
50
 
51
51
  def coordinates=(value)
52
- coords =
53
- case value
52
+ coords = case value
54
53
  when self.class
55
54
  deep_copy_array(value.coordinates)
56
55
  when Hash
@@ -76,6 +75,7 @@ module Parse
76
75
  def to_geojson
77
76
  { "type" => geojson_type, "coordinates" => deep_copy_array(@coordinates) }
78
77
  end
78
+
79
79
  alias_method :as_json, :to_geojson
80
80
 
81
81
  # @return [String] the JSON form, suitable for direct shipment to
@@ -109,6 +109,7 @@ module Parse
109
109
  km = 0 if km.nil?
110
110
  [@latitude, @longitude, km, :km]
111
111
  end
112
+
112
113
  alias_method :max_km, :max_kilometers
113
114
 
114
115
  # Helper method for performing geo-queries with a radial radians
@@ -403,11 +403,11 @@ module Parse
403
403
  # @return [Parse::ACL] the current default ACLs for this class.
404
404
  def default_acls
405
405
  @default_acls ||= case acl_policy_setting
406
- when :public, :owner_else_public then Parse::ACL.everyone
407
- when :public_read, :owner_but_public_read then Parse::ACL.everyone(true, false)
408
- when :private, :owner_else_private then Parse::ACL.private
409
- else Parse::ACL.everyone
410
- end
406
+ when :public, :owner_else_public then Parse::ACL.everyone
407
+ when :public_read, :owner_but_public_read then Parse::ACL.everyone(true, false)
408
+ when :private, :owner_else_private then Parse::ACL.private
409
+ else Parse::ACL.everyone
410
+ end
411
411
  end
412
412
 
413
413
  # A method to set default ACLs to be applied for newly created
@@ -541,10 +541,10 @@ module Parse
541
541
  end
542
542
  if owner.nil? && policy.to_s.start_with?("owner_")
543
543
  fallback = case policy
544
- when :owner_else_public then "public R/W"
545
- when :owner_but_public_read then "public read only"
546
- else "master-key-only"
547
- end
544
+ when :owner_else_public then "public R/W"
545
+ when :owner_but_public_read then "public read only"
546
+ else "master-key-only"
547
+ end
548
548
  warn "[#{self}] acl_policy #{policy.inspect} declared without `owner:` field; ACL resolution will always use the fallback (#{fallback}). Pass `as:` at construction to override."
549
549
  end
550
550
  @acl_policy_setting = policy
@@ -698,7 +698,7 @@ module Parse
698
698
  class_permissions.set_default_permission(
699
699
  public_access: public,
700
700
  roles: Array(roles),
701
- requires_authentication: requires_authentication
701
+ requires_authentication: requires_authentication,
702
702
  )
703
703
 
704
704
  # Also explicitly set all operations to ensure they're included
@@ -789,7 +789,7 @@ module Parse
789
789
  roles: Array(roles),
790
790
  users: Array(users),
791
791
  pointer_fields: converted_pointer_fields,
792
- requires_authentication: requires_authentication
792
+ requires_authentication: requires_authentication,
793
793
  )
794
794
  end
795
795
 
@@ -1043,8 +1043,8 @@ module Parse
1043
1043
 
1044
1044
  per_field[local_sym] = {
1045
1045
  write: guards_map[local_sym] || :open,
1046
- read: hidden_from.empty? ? :open : { hidden_from: hidden_from },
1047
- type: data_type,
1046
+ read: hidden_from.empty? ? :open : { hidden_from: hidden_from },
1047
+ type: data_type,
1048
1048
  }
1049
1049
  end
1050
1050
 
@@ -1057,10 +1057,10 @@ module Parse
1057
1057
  end
1058
1058
 
1059
1059
  {
1060
- operations: operations,
1061
- read_user_fields: perms.respond_to?(:read_user_fields) ? perms.read_user_fields : [],
1060
+ operations: operations,
1061
+ read_user_fields: perms.respond_to?(:read_user_fields) ? perms.read_user_fields : [],
1062
1062
  write_user_fields: perms.respond_to?(:write_user_fields) ? perms.write_user_fields : [],
1063
- fields: per_field,
1063
+ fields: per_field,
1064
1064
  }
1065
1065
  end
1066
1066
 
@@ -1241,8 +1241,7 @@ module Parse
1241
1241
  # for tests or internal mongo-direct bulk writes). A class may flip
1242
1242
  # the per-class default with `vector_visibility :public`; an explicit
1243
1243
  # `include_vectors:` in the call always wins over the class default.
1244
- include_vectors =
1245
- if opts.key?(:include_vectors)
1244
+ include_vectors = if opts.key?(:include_vectors)
1246
1245
  opts[:include_vectors] == true
1247
1246
  else
1248
1247
  self.class.respond_to?(:vectors_public_by_default?) && self.class.vectors_public_by_default?
@@ -1351,6 +1350,12 @@ module Parse
1351
1350
  # the trust signal here.
1352
1351
  trusted = @_trusted_init == true
1353
1352
  @_trusted_init = nil
1353
+ input_hash = opts.is_a?(Hash) ? opts : nil
1354
+ input_had_acl = input_hash && %w[ACL acl].any? do |key|
1355
+ input_hash.key?(key) || input_hash.key?(key.to_sym)
1356
+ end
1357
+ input_had_id = input_hash && (input_hash.key?(Parse::Model::OBJECT_ID) || input_hash.key?(:objectId) ||
1358
+ input_hash.key?(:id) || input_hash.key?(Parse::Model::ID))
1354
1359
  acl_owner_override = nil
1355
1360
  if opts.is_a?(String) #then it's the objectId
1356
1361
  @id = opts.to_s
@@ -1398,6 +1403,31 @@ module Parse
1398
1403
  @_acl_pristine = !acl_was_user_supplied
1399
1404
  @_acl_owner_override = acl_owner_override
1400
1405
 
1406
+ # Record where our ACL knowledge came from. `acl.nil?` alone is not
1407
+ # enough: it can mean a genuinely ACL-less (therefore public) server
1408
+ # row, but it also describes an id-only pointer or a selective response
1409
+ # that never fetched ACL. Conversely, a custom class may stamp its local
1410
+ # default ACL while hydrating a full server row whose ACL was absent.
1411
+ # Access inspection uses this evidence to distinguish those cases.
1412
+ @_authorization_acl_state = if trusted
1413
+ if has_selective_keys? && !input_had_acl
1414
+ :unknown
1415
+ elsif input_had_acl
1416
+ self.acl.nil? ? :absent : :present
1417
+ else
1418
+ :absent
1419
+ end
1420
+ elsif opts.is_a?(String) || input_had_id
1421
+ :unknown
1422
+ elsif input_had_acl
1423
+ self.acl.nil? ? :unknown : :local
1424
+ elsif self.class.builtin_acl_default_active?
1425
+ :unknown
1426
+ else
1427
+ :local
1428
+ end
1429
+ @_authorization_acl_tracking_ready = true
1430
+
1401
1431
  # One-time per-class permissive-default warning. Fires only when the
1402
1432
  # effective policy is :public or :owner_else_public.
1403
1433
  self.class._warn_permissive_acl_default_once
@@ -1617,6 +1647,39 @@ module Parse
1617
1647
  @_fetched_keys.include?(key) || (remote_key && @_fetched_keys.include?(remote_key))
1618
1648
  end
1619
1649
 
1650
+ # Describes whether the in-memory ACL is authoritative enough for local
1651
+ # access inspection. `:absent` means a full trusted server response omitted
1652
+ # ACL (Parse Server's public default); `:present` and `:local` have a usable
1653
+ # ACL value; `:unknown` covers pointers and incomplete hydration.
1654
+ #
1655
+ # @return [Symbol] `:present`, `:absent`, `:local`, or `:unknown`.
1656
+ # @api private
1657
+ def authorization_acl_state
1658
+ @_authorization_acl_state || :unknown
1659
+ end
1660
+
1661
+ # Update ACL evidence after `fetch!` applies a server response to an
1662
+ # existing instance. Save/update responses are deliberately excluded: they
1663
+ # are deltas and omission there says nothing about the stored ACL.
1664
+ # @param attributes [Hash] trusted server response.
1665
+ # @param partial [Boolean] whether a selective key fetch produced it.
1666
+ # @api private
1667
+ def record_authorization_hydration!(attributes, partial: false)
1668
+ return unless attributes.is_a?(Hash)
1669
+ has_acl = %w[ACL acl].any? do |key|
1670
+ attributes.key?(key) || attributes.key?(key.to_sym)
1671
+ end
1672
+ @_authorization_acl_state = if has_acl
1673
+ self.acl.nil? ? :absent : :present
1674
+ elsif partial
1675
+ :unknown
1676
+ else
1677
+ :absent
1678
+ end
1679
+ @_authorization_acl_tracking_ready = true
1680
+ @_authorization_acl_state
1681
+ end
1682
+
1620
1683
  # Returns the nested fetched keys map for building nested objects.
1621
1684
  # @return [Hash] map of field names to their fetched keys
1622
1685
  def nested_fetched_keys
@@ -1884,48 +1947,48 @@ module Parse
1884
1947
  owner = @_acl_owner_override if defined?(@_acl_owner_override)
1885
1948
  if owner.nil? && (field = self.class.acl_owner_field)
1886
1949
  owner = if field == :self
1887
- # Self-referential ownership (Parse::User only — enforced at
1888
- # declaration time). Pre-generate a Parse-compatible objectId
1889
- # client-side so the ACL grant can reference the record's own
1890
- # id in the same POST body that creates it. Skipped when the id
1891
- # is already set (e.g. when re-saving an existing user, or when
1892
- # parse_reference precompute already ran).
1893
- @id = Parse::Core::ParseReference.generate_object_id if @id.blank?
1894
- @id
1895
- elsif respond_to?(field)
1896
- send(field)
1897
- end
1950
+ # Self-referential ownership (Parse::User only — enforced at
1951
+ # declaration time). Pre-generate a Parse-compatible objectId
1952
+ # client-side so the ACL grant can reference the record's own
1953
+ # id in the same POST body that creates it. Skipped when the id
1954
+ # is already set (e.g. when re-saving an existing user, or when
1955
+ # parse_reference precompute already ran).
1956
+ @id = Parse::Core::ParseReference.generate_object_id if @id.blank?
1957
+ @id
1958
+ elsif respond_to?(field)
1959
+ send(field)
1960
+ end
1898
1961
  end
1899
1962
  owner_id = _resolve_acl_owner_id(owner)
1900
1963
 
1901
1964
  target_acl = case policy
1902
- when :public
1903
- Parse::ACL.everyone(true, true)
1904
- when :public_read
1905
- Parse::ACL.everyone(true, false)
1906
- when :private
1907
- Parse::ACL.private
1908
- when :owner_else_public
1909
- if owner_id
1910
- acl = Parse::ACL.new
1911
- acl.apply(owner_id, true, true)
1912
- acl
1913
- else
1914
- Parse::ACL.everyone(true, true)
1915
- end
1916
- when :owner_else_private
1917
- if owner_id
1918
- acl = Parse::ACL.new
1919
- acl.apply(owner_id, true, true)
1920
- acl
1921
- else
1922
- Parse::ACL.private
1923
- end
1924
- when :owner_but_public_read
1925
- acl = Parse::ACL.everyone(true, false)
1926
- acl.apply(owner_id, true, true) if owner_id
1927
- acl
1928
- end
1965
+ when :public
1966
+ Parse::ACL.everyone(true, true)
1967
+ when :public_read
1968
+ Parse::ACL.everyone(true, false)
1969
+ when :private
1970
+ Parse::ACL.private
1971
+ when :owner_else_public
1972
+ if owner_id
1973
+ acl = Parse::ACL.new
1974
+ acl.apply(owner_id, true, true)
1975
+ acl
1976
+ else
1977
+ Parse::ACL.everyone(true, true)
1978
+ end
1979
+ when :owner_else_private
1980
+ if owner_id
1981
+ acl = Parse::ACL.new
1982
+ acl.apply(owner_id, true, true)
1983
+ acl
1984
+ else
1985
+ Parse::ACL.private
1986
+ end
1987
+ when :owner_but_public_read
1988
+ acl = Parse::ACL.everyone(true, false)
1989
+ acl.apply(owner_id, true, true) if owner_id
1990
+ acl
1991
+ end
1929
1992
 
1930
1993
  # Only re-stamp if the resolved ACL differs from the init-time stamp;
1931
1994
  # this avoids an unnecessary dirty mark on the acl field for `:public`
@@ -1985,6 +2048,9 @@ module Parse
1985
2048
  @_acl_snapshot_before_change = @acl ? Parse::ACL.new(@acl.as_json) : Parse::ACL.new
1986
2049
  end
1987
2050
  @_acl_pristine = false if defined?(@_acl_pristine)
2051
+ if defined?(@_authorization_acl_tracking_ready) && @_authorization_acl_tracking_ready
2052
+ @_authorization_acl_state = :local
2053
+ end
1988
2054
  super
1989
2055
  end
1990
2056
 
@@ -2156,16 +2222,16 @@ class Array
2156
2222
  next m if m.is_a?(Parse::Pointer)
2157
2223
  if m.is_a?(Hash)
2158
2224
  resolved = if className
2159
- # Caller knows the type; warn on mismatch but always
2160
- # use the declared className.
2161
- incoming = m[f] || m[:className]
2162
- if incoming && !Parse::Model.same_parse_class?(incoming, className)
2163
- warn "[Parse::Array#parse_objects] expected className=#{className.inspect}, ignoring incoming className=#{incoming.inspect}"
2164
- end
2165
- className
2166
- else
2167
- m[f] || m[:className]
2168
- end
2225
+ # Caller knows the type; warn on mismatch but always
2226
+ # use the declared className.
2227
+ incoming = m[f] || m[:className]
2228
+ if incoming && !Parse::Model.same_parse_class?(incoming, className)
2229
+ warn "[Parse::Array#parse_objects] expected className=#{className.inspect}, ignoring incoming className=#{incoming.inspect}"
2230
+ end
2231
+ className
2232
+ else
2233
+ m[f] || m[:className]
2234
+ end
2169
2235
  next Parse::Object.build(m, resolved) if resolved
2170
2236
  end
2171
2237
  nil
@@ -118,8 +118,10 @@ module Parse
118
118
  # nil unless the instance came from the corresponding search path.
119
119
  # @return [Float, nil]
120
120
  def vector_score; @_vector_score; end
121
+
121
122
  # @return [Float, nil]
122
123
  def search_score; @_search_score; end
124
+
123
125
  # @return [Hash, nil]
124
126
  def search_highlights; @_search_highlights; end
125
127
 
@@ -99,8 +99,7 @@ module Parse
99
99
  end
100
100
  outer = rings.first
101
101
  pairs = outer.map do |(lng, lat)|
102
- raise ArgumentError, "[Parse::Polygon] GeoJSON ring entries must be [lng, lat] numeric pairs." \
103
- unless lng.is_a?(Numeric) && lat.is_a?(Numeric)
102
+ raise ArgumentError, "[Parse::Polygon] GeoJSON ring entries must be [lng, lat] numeric pairs." unless lng.is_a?(Numeric) && lat.is_a?(Numeric)
104
103
  [lat.to_f, lng.to_f]
105
104
  end
106
105
  new(pairs)
@@ -120,8 +119,7 @@ module Parse
120
119
  # - a Hash with a `coordinates` key (the Parse REST wire shape)
121
120
  # - another {Parse::Polygon}
122
121
  def coordinates=(value)
123
- coords =
124
- case value
122
+ coords = case value
125
123
  when Parse::Polygon
126
124
  # Duplicate so external mutation of the source doesn't leak in.
127
125
  value.coordinates.map { |pair| pair.dup }
@@ -270,8 +268,7 @@ module Parse
270
268
  # @param point [Parse::GeoPoint, Array<Numeric>] the point to test.
271
269
  # @return [Boolean]
272
270
  def contains_point?(point)
273
- lat, lng =
274
- case point
271
+ lat, lng = case point
275
272
  when Parse::GeoPoint then [point.latitude, point.longitude]
276
273
  when Array then [point[0].to_f, point[1].to_f]
277
274
  else
@@ -963,11 +963,11 @@ module Parse
963
963
  if device_type.present? && !SUPPORTED_PUSH_DEVICE_TYPES.include?(device_type)
964
964
  if UNSUPPORTED_PUSH_DEVICE_TYPES.include?(device_type)
965
965
  warn "[Parse::Push] Warning: device_type '#{device_type}' may not be supported for push notifications. " \
966
- "Supported types: #{SUPPORTED_PUSH_DEVICE_TYPES.join(', ')}"
966
+ "Supported types: #{SUPPORTED_PUSH_DEVICE_TYPES.join(", ")}"
967
967
  else
968
968
  warn "[Parse::Push] Warning: unknown device_type '#{device_type}' for installation #{installation_id}. " \
969
969
  "This device type may not receive push notifications. " \
970
- "Supported types: #{SUPPORTED_PUSH_DEVICE_TYPES.join(', ')}"
970
+ "Supported types: #{SUPPORTED_PUSH_DEVICE_TYPES.join(", ")}"
971
971
  end
972
972
  end
973
973
  end
@@ -57,6 +57,7 @@ module Parse
57
57
  def dimensions
58
58
  @values.length
59
59
  end
60
+
60
61
  alias_method :length, :dimensions
61
62
  alias_method :size, :dimensions
62
63
 
@@ -84,10 +85,11 @@ module Parse
84
85
  def ==(other)
85
86
  case other
86
87
  when Parse::Vector then @values == other.values
87
- when Array then @values == other
88
+ when Array then @values == other
88
89
  else false
89
90
  end
90
91
  end
92
+
91
93
  alias_method :eql?, :==
92
94
 
93
95
  def hash