parse-stack-next 5.8.0 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +318 -0
  3. data/README.md +25 -19
  4. data/docs/caching.md +50 -0
  5. data/docs/mcp_guide.md +15 -3
  6. data/docs/webhooks_guide.md +59 -7
  7. data/lib/parse/acl_scope.rb +71 -9
  8. data/lib/parse/agent/describe.rb +8 -2
  9. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  10. data/lib/parse/agent.rb +205 -20
  11. data/lib/parse/atlas_search/index_manager.rb +5 -1
  12. data/lib/parse/atlas_search.rb +49 -1
  13. data/lib/parse/authorization.rb +235 -3
  14. data/lib/parse/cache/sub_cache.rb +26 -0
  15. data/lib/parse/client/authentication.rb +19 -1
  16. data/lib/parse/client/batch.rb +35 -2
  17. data/lib/parse/client.rb +26 -2
  18. data/lib/parse/clp_scope.rb +37 -3
  19. data/lib/parse/live_query/client.rb +72 -2
  20. data/lib/parse/lock_backend.rb +4 -3
  21. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  22. data/lib/parse/model/associations/has_many.rb +11 -0
  23. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  24. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  25. data/lib/parse/model/classes/role.rb +23 -0
  26. data/lib/parse/model/classes/session.rb +279 -19
  27. data/lib/parse/model/classes/user.rb +33 -12
  28. data/lib/parse/model/core/actions.rb +19 -4
  29. data/lib/parse/model/core/fetching.rb +29 -2
  30. data/lib/parse/model/core/field_guards.rb +16 -8
  31. data/lib/parse/model/object.rb +52 -1
  32. data/lib/parse/model/push.rb +11 -1
  33. data/lib/parse/mongodb.rb +19 -1
  34. data/lib/parse/query/constraint.rb +1 -1
  35. data/lib/parse/query/constraints.rb +31 -27
  36. data/lib/parse/query.rb +793 -55
  37. data/lib/parse/stack/version.rb +1 -1
  38. data/lib/parse/stack.rb +19 -0
  39. data/lib/parse/vector_search/hybrid.rb +74 -15
  40. data/lib/parse/vector_search.rb +214 -7
  41. data/lib/parse/webhooks/payload.rb +43 -0
  42. data/lib/parse/webhooks.rb +312 -9
  43. metadata +1 -1
@@ -90,7 +90,9 @@ module Parse
90
90
  opts.delete(:session_token)
91
91
  response = client.fetch_session(token, **opts)
92
92
  if response.success?
93
- return Parse::Session.build response.result
93
+ built = Parse::Session.build response.result
94
+ _remember_owners!([built], client)
95
+ return built
94
96
  end
95
97
  nil
96
98
  end
@@ -205,29 +207,218 @@ module Parse
205
207
  destroy
206
208
  end
207
209
 
208
- # Deletes the session and, on success, forgets it in the client's
209
- # identity plane so mongo-direct and Atlas Search reads stop resolving
210
- # the token at once instead of when the cached entry expires. The token
211
- # is dropped when this instance carries it; the owning user's entries
212
- # are dropped as well, since a session fetched without the master key
213
- # does not carry its token.
210
+ # Deletes the session and forgets it in the client's identity plane, so
211
+ # mongo-direct and Atlas Search reads stop resolving the token at once
212
+ # instead of when the cached entry expires. Both the token and the owning
213
+ # user's entries are dropped. A session that does not carry them (built
214
+ # from its objectId, or fetched without the master key or with `keys:`
215
+ # leaving them out) has them looked up first: with the master key as SDK
216
+ # metadata when the client has one, otherwise with the `session:` passed
217
+ # here. A lookup that finds no row leaves nothing to forget. Only a
218
+ # lookup that fails outright drops every cached identity on the client,
219
+ # at most once every {RESET_INTERVAL} seconds per client.
220
+ #
221
+ # The entries are dropped when the delete succeeds or reports the row
222
+ # is already gone (or not visible to the caller). A delete that raises
223
+ # still drops the known token, which is harmless, but does not touch the
224
+ # owner's entries.
225
+ #
226
+ # A batch delete through `Array#destroy` drops the same entries for
227
+ # each session in it. With an identity plane that cannot drop a
228
+ # single user's entries (a custom plane without `bump_generation` or
229
+ # `invalidate_value`), a revoked token keeps resolving until its cached
230
+ # entry expires.
214
231
  # @param session [String] (see Parse::Object#destroy)
215
232
  # @return [Boolean] whether the operation was successful.
216
233
  def destroy(session: nil)
217
- token = @session_token
218
- # Read the association ivar directly: calling `user` on a partially
219
- # fetched session could trigger an autofetch just to learn the owner.
220
- owner = @user
221
- owner_id = owner.respond_to?(:id) ? owner.id : nil
222
- success = super
223
- if success
224
- cl = client
225
- cl.invalidate_session_identity(token) if token.is_a?(String) && cl.respond_to?(:invalidate_session_identity)
226
- cl.invalidate_user_identity(owner_id) if owner_id && cl.respond_to?(:invalidate_user_identity)
234
+ # A session never saved is not deleted (see Parse::Object#destroy), so
235
+ # there is nothing to forget either.
236
+ return super if new?
237
+ token = nil
238
+ owner_id = nil
239
+ begin
240
+ Parse::Session.send(:_preload_identity_for_destroy!, [self], session_token: session)
241
+ token, owner_id = _identity_for_destroy
242
+ ensure
243
+ # The looked-up token is a live credential: keep it in locals only.
244
+ _clear_identity_for_destroy!
245
+ end
246
+ result = nil
247
+ begin
248
+ result = super
249
+ ensure
250
+ # `false` covers "object not found", which is either a session
251
+ # already revoked elsewhere or one the caller cannot see; dropping
252
+ # cached entries is idempotent in both cases. A raised delete still
253
+ # drops the token it named but leaves the owner alone. A single
254
+ # delete cannot tell "already gone" from "denied", so an absent row
255
+ # only uses the recorded owner, never the reset fallback.
256
+ if result.nil?
257
+ _forget_identity!(token.is_a?(String) ? token : nil, nil)
258
+ else
259
+ _forget_identity!(token, owner_id, reset_fallback: false)
260
+ end
227
261
  end
228
- success
229
262
  end
230
263
 
264
+ # Seconds between two full identity-cache resets triggered by a failed
265
+ # session lookup on the same client.
266
+ RESET_INTERVAL = 5
267
+
268
+ # Process-local record of the last lookup-triggered reset per client.
269
+ @identity_reset_at = {}
270
+ @identity_reset_mutex = Mutex.new
271
+
272
+ class << self
273
+ # Record, in the identity context of `cl` (the client that fetched
274
+ # them), which user owns each session that carries both its objectId
275
+ # and its owner, so a later delete can drop the owner's cached
276
+ # identities after the row is gone. Never raises: it runs while query
277
+ # results are decoded.
278
+ # @param sessions [Array<Parse::Object>]
279
+ # @param cl [Parse::Client]
280
+ # @!visibility private
281
+ def _remember_owners!(sessions, cl)
282
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
283
+ return unless auth.respond_to?(:remember_session_owner)
284
+ Array(sessions).each do |o|
285
+ next unless o.is_a?(Parse::Session)
286
+ owner_id = o.send(:_identity_owner_id)
287
+ id = o.id
288
+ auth.remember_session_owner(id, owner_id) if owner_id && id.present?
289
+ end
290
+ nil
291
+ rescue StandardError
292
+ nil
293
+ end
294
+
295
+ private
296
+
297
+ # Reset `cl`'s identity and role caches, at most once every
298
+ # {RESET_INTERVAL} seconds per client.
299
+ # @!visibility private
300
+ def _rate_limited_identity_reset!(cl)
301
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
302
+ return unless auth.respond_to?(:reset_caches!)
303
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
304
+ due = @identity_reset_mutex.synchronize do
305
+ last = @identity_reset_at[cl.object_id]
306
+ next false if last && now - last < RESET_INTERVAL
307
+ @identity_reset_at[cl.object_id] = now
308
+ true
309
+ end
310
+ auth.reset_caches! if due
311
+ end
312
+
313
+ # Look up the token and owner of every session about to be deleted that
314
+ # does not carry them, so the delete can drop their identity entries.
315
+ # One `_Session` query per client. A client with a master key reads it
316
+ # as SDK metadata (it works inside `Parse.without_master_key`); one
317
+ # without reads it with `session_token`, or skips the lookup when there
318
+ # is none. The query never uses the response cache: its rows carry live
319
+ # session tokens.
320
+ #
321
+ # A row the lookup did not return is marked absent: there is nothing
322
+ # to forget for it. Only a lookup that raised marks its sessions
323
+ # `:unknown`, which makes their delete reset the client's identity
324
+ # cache (rate limited).
325
+ # @param sessions [Array<Parse::Object>]
326
+ # @param session_token [String, nil] the delete's own session.
327
+ # @!visibility private
328
+ def _preload_identity_for_destroy!(sessions, session_token: nil)
329
+ pending = sessions.select { |o| o.is_a?(Parse::Session) && o.send(:_identity_lookup_needed?) }
330
+ return if pending.empty?
331
+ pending.group_by(&:client).each do |cl, group|
332
+ has_master = cl.respond_to?(:master_key) && cl.master_key.present?
333
+ token = session_token.respond_to?(:session_token) ? session_token.session_token : session_token
334
+ token = nil unless token.is_a?(String) && !token.strip.empty?
335
+ unless has_master || token
336
+ group.each { |o| o.instance_variable_set(:@_identity_for_destroy, :absent) }
337
+ next
338
+ end
339
+ ids = group.map(&:id).uniq
340
+ found = begin
341
+ query = Parse::Session.query(:objectId.in => ids, limit: ids.size)
342
+ query.keys(:session_token, :user)
343
+ query.client = cl
344
+ query.cache = false
345
+ if has_master
346
+ query.instance_variable_set(:@_metadata_master, true)
347
+ else
348
+ query.session_token = token
349
+ end
350
+ query.results.to_h do |row|
351
+ owner = row.instance_variable_get(:@user)
352
+ [row.id, [row.instance_variable_get(:@session_token), owner.respond_to?(:id) ? owner.id : nil]]
353
+ end
354
+ rescue StandardError => e
355
+ warn "[Parse::Session] could not look up the token and owner of " \
356
+ "#{ids.size} session(s) before deleting them (#{e.class}); " \
357
+ "dropping the whole identity cache instead."
358
+ nil
359
+ end
360
+ group.each do |o|
361
+ mark = if found.nil?
362
+ :unknown
363
+ elsif (row = found[o.id]) && (row[0].is_a?(String) || row[1])
364
+ row
365
+ else
366
+ :absent
367
+ end
368
+ o.instance_variable_set(:@_identity_for_destroy, mark)
369
+ end
370
+ end
371
+ end
372
+ end
373
+
374
+ # Called by `Array#destroy` for each session in the batch with its
375
+ # response. The entries are dropped only when the delete succeeded or
376
+ # reported the row gone ("object not found").
377
+ # @!visibility private
378
+ def _after_batch_destroy(response = nil)
379
+ identity = _identity_for_destroy
380
+ _clear_identity_for_destroy!
381
+ return unless Parse::Session.send(:_destroy_applied?, response)
382
+ # The batch response tells success and "object not found" apart from
383
+ # a denial, so an absent row whose owner was never recorded may fall
384
+ # back to the rate-limited reset here.
385
+ _forget_identity!(*identity, reset_fallback: true)
386
+ end
387
+ private :_after_batch_destroy
388
+
389
+ # Whether a batch delete response means the row is gone: success, or
390
+ # "object not found". A missing response (older callers) counts as
391
+ # applied.
392
+ # @!visibility private
393
+ def self._destroy_applied?(response)
394
+ return true if response.nil?
395
+ (response.respond_to?(:success?) && response.success?) ||
396
+ (response.respond_to?(:object_not_found?) && response.object_not_found?)
397
+ end
398
+ private_class_method :_destroy_applied?
399
+
400
+ # Remove the token and owner {._preload_identity_for_destroy!} looked
401
+ # up. The token is a live credential and must not outlive the delete
402
+ # (it would show in `inspect` or `instance_variables`).
403
+ # @!visibility private
404
+ def _clear_identity_for_destroy!
405
+ remove_instance_variable(:@_identity_for_destroy) if instance_variable_defined?(:@_identity_for_destroy)
406
+ end
407
+ private :_clear_identity_for_destroy!
408
+
409
+ # Whether this session lacks the token or the owner its identity-plane
410
+ # entries are keyed by: a fetch without the master key (no token) or
411
+ # with `keys:` leaving them out.
412
+ # @!visibility private
413
+ def _identity_lookup_needed?
414
+ # Keyed on the objectId, not `new?`: `Array#destroy` deletes by id
415
+ # alone, so a session built from an id (no timestamps loaded) is still
416
+ # deleted and its token must still be dropped.
417
+ return false if @id.blank?
418
+ !@session_token.is_a?(String) || @session_token.empty? || _identity_owner_id.nil?
419
+ end
420
+ private :_identity_lookup_needed?
421
+
231
422
  # Serialization omits `sessionToken` unless `include_session_token: true`
232
423
  # is passed. A session token is a bearer credential, and `as_json` is the
233
424
  # surface that reaches logs, API responses and agent tool output.
@@ -245,7 +436,76 @@ module Parse
245
436
  # Redacts the session token from the default inspect output.
246
437
  # @return [String]
247
438
  def inspect
248
- Parse::User.redact_session_token(super, @session_token)
439
+ looked_up = @_identity_for_destroy
440
+ Parse::User.redact_session_token(super, @session_token, looked_up.is_a?(Array) ? looked_up[0] : nil)
441
+ end
442
+
443
+ private
444
+
445
+ # The token and owner id to forget for this delete: what this instance
446
+ # carries, completed by {._preload_identity_for_destroy!} when it did
447
+ # not, or `:unknown` when that lookup failed.
448
+ # @!visibility private
449
+ def _identity_for_destroy
450
+ looked_up = @_identity_for_destroy
451
+ token = @session_token.is_a?(String) && !@session_token.empty? ? @session_token : nil
452
+ owner_id = _identity_owner_id
453
+ return [:unknown, owner_id] if looked_up == :unknown
454
+ if looked_up == :absent
455
+ # No row to read: the session is gone or not visible. Forget what
456
+ # this instance carries; with nothing at all, mark it absent so the
457
+ # delete can fall back to the recorded owner.
458
+ return token || owner_id ? [token, owner_id] : [:absent, nil]
459
+ end
460
+ if looked_up.is_a?(Array)
461
+ token ||= looked_up[0]
462
+ owner_id ||= looked_up[1]
463
+ end
464
+ [token, owner_id]
465
+ end
466
+
467
+ # The owning user's id, read from the association ivar directly:
468
+ # calling `user` on a partially fetched session could trigger an
469
+ # autofetch just to learn the owner.
470
+ # @!visibility private
471
+ def _identity_owner_id
472
+ owner = @user
473
+ owner.respond_to?(:id) ? owner.id : nil
474
+ end
475
+
476
+ # Drop the token and the owner's entries from this session's client's
477
+ # identity plane.
478
+ # @!visibility private
479
+ def _forget_identity!(token, owner_id, reset_fallback: false)
480
+ cl = client
481
+ if token == :absent
482
+ # The row was not readable before the delete. Use the owner recorded
483
+ # when this session was last loaded; without one, and only when the
484
+ # delete is known to have applied or found the row gone, fall back to
485
+ # the rate-limited reset. Never on a denied delete.
486
+ token = nil
487
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
488
+ recorded = auth.respond_to?(:session_owner) ? auth.session_owner(@id) : nil
489
+ if recorded
490
+ owner_id ||= recorded
491
+ elsif owner_id.nil? && reset_fallback
492
+ Parse::Session.send(:_rate_limited_identity_reset!, cl)
493
+ end
494
+ end
495
+ if token == :unknown
496
+ # The lookup failed, so the token's entry cannot be named. Drop every
497
+ # cached identity on this client (the next read of each token
498
+ # re-resolves it, which is safe), at most once every RESET_INTERVAL
499
+ # seconds so repeated failures cannot flush a shared plane on every
500
+ # request. The owner's entries go as well when the owner is known.
501
+ Parse::Session.send(:_rate_limited_identity_reset!, cl)
502
+ token = nil
503
+ end
504
+ cl.invalidate_session_identity(token) if token.is_a?(String) && cl.respond_to?(:invalidate_session_identity)
505
+ cl.invalidate_user_identity(owner_id) if owner_id && cl.respond_to?(:invalidate_user_identity)
506
+ rescue StandardError
507
+ # Runs from an `ensure`: never replace the delete's own outcome.
508
+ nil
249
509
  end
250
510
  end
251
511
  end
@@ -712,7 +712,7 @@ module Parse
712
712
 
713
713
  if response.success?
714
714
  result = response.result || {}
715
- @updated_at = result["updatedAt"] || @updated_at
715
+ @updated_at = _server_date(result["updatedAt"]) || @updated_at
716
716
  # Parse Server may rotate the session token on a credential
717
717
  # change; apply it narrowly if present without going through the
718
718
  # full property writer chain.
@@ -826,8 +826,8 @@ module Parse
826
826
  # save-as-signup path had already addressed.
827
827
  result = response.result
828
828
  @id = result[Parse::Model::OBJECT_ID] || @id
829
- @created_at = result["createdAt"] || @created_at
830
- @updated_at = result["updatedAt"] || result["createdAt"] || @updated_at
829
+ @created_at = _server_date(result["createdAt"]) || @created_at
830
+ @updated_at = _server_date(result["updatedAt"] || result["createdAt"]) || @updated_at
831
831
  set_attributes!(result.slice(*SIGNUP_RESPONSE_APPLY_KEYS))
832
832
  # Drop the plaintext password from memory now that the server
833
833
  # has it hashed and we no longer need it. Matches the Parse JS
@@ -956,19 +956,40 @@ module Parse
956
956
  result
957
957
  end
958
958
 
959
- # Deletes the user. On success every cached identity entry for the user
960
- # is dropped from this client's identity plane, since Parse Server
961
- # removes the user's sessions with the account.
959
+ # Deletes the user. Every cached identity entry for the user is dropped
960
+ # from this client's identity plane, since Parse Server removes the
961
+ # user's sessions with the account. The entries are dropped when the
962
+ # delete returns, successful or not (a failure is usually "object not
963
+ # found": the account is already gone or not visible to the caller, and
964
+ # dropping cached entries is idempotent). A delete that raises leaves
965
+ # them alone.
962
966
  # @param session [String] (see Parse::Object#destroy)
963
967
  # @return [Boolean] whether the operation was successful.
964
968
  def destroy(session: nil)
965
969
  user_id = id
966
- success = super
967
- if success && client.respond_to?(:invalidate_user_identity)
968
- client.invalidate_user_identity(user_id)
970
+ result = super
971
+ begin
972
+ client.invalidate_user_identity(user_id) if user_id.present? && client.respond_to?(:invalidate_user_identity)
973
+ rescue StandardError
974
+ # Never replace the delete's own outcome.
975
+ nil
969
976
  end
970
- success
977
+ result
978
+ end
979
+
980
+ # Called by `Array#destroy` for each user in the batch with its
981
+ # response. Drops the user's identity entries, as {#destroy} does, when
982
+ # the delete succeeded or the row is already gone; a denied delete
983
+ # leaves them alone.
984
+ # @!visibility private
985
+ def _after_batch_destroy(response = nil)
986
+ applied = response.nil? ||
987
+ (response.respond_to?(:success?) && response.success?) ||
988
+ (response.respond_to?(:object_not_found?) && response.object_not_found?)
989
+ return unless applied
990
+ client.invalidate_user_identity(id) if id.present? && client.respond_to?(:invalidate_user_identity)
971
991
  end
992
+ private :_after_batch_destroy
972
993
 
973
994
  # Invalid the current session token for this logged in user.
974
995
  # @return [Boolean] True/false if successful
@@ -1729,8 +1750,8 @@ module Parse
1729
1750
  unless res.error?
1730
1751
  result = res.result
1731
1752
  @id = result[Parse::Model::OBJECT_ID] || @id
1732
- @created_at = result["createdAt"] || @created_at
1733
- @updated_at = result["updatedAt"] || result["createdAt"] || @updated_at
1753
+ @created_at = _server_date(result["createdAt"]) || @created_at
1754
+ @updated_at = _server_date(result["updatedAt"] || result["createdAt"]) || @updated_at
1734
1755
  # Plaintext password is no longer needed locally; the server
1735
1756
  # has it hashed. Direct ivar assignment avoids re-dirtying the
1736
1757
  # field.
@@ -554,7 +554,13 @@ module Parse
554
554
 
555
555
  # Apply any additional attributes returned by beforeSave hooks.
556
556
  obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
557
- obj.send(:clear_changes!) if obj.respond_to?(:clear_changes!, true)
557
+ # The transaction carried the record's relation ops too, so
558
+ # settle them as a save does rather than discarding them.
559
+ if obj.respond_to?(:changes_applied!, true)
560
+ obj.send(:changes_applied!)
561
+ elsif obj.respond_to?(:clear_changes!, true)
562
+ obj.send(:clear_changes!)
563
+ end
558
564
  end
559
565
 
560
566
  return responses
@@ -1108,6 +1114,8 @@ module Parse
1108
1114
  # @param op_hash [Hash] The operation hash. It may also be of type {Parse::RelationAction}.
1109
1115
  # @return [Boolean] whether the operation was successful.
1110
1116
  def operate_field!(field, op_hash)
1117
+ # A reply from an earlier operation must never be read as this one's.
1118
+ @_last_operation_result = nil
1111
1119
  field = field.to_sym
1112
1120
  field = self.field_map[field] || field
1113
1121
  if op_hash.is_a?(Parse::RelationAction)
@@ -1132,9 +1140,12 @@ module Parse
1132
1140
 
1133
1141
  # @!visibility private
1134
1142
  # The value Parse Server returned for `field` from the last
1135
- # {#operate_field!}, or nil when the reply did not include it.
1143
+ # {#operate_field!}, or nil when the reply did not include it. The
1144
+ # reply is read once: it is forgotten here so a later read cannot
1145
+ # pick up a stale value.
1136
1146
  def _last_operation_value(field)
1137
1147
  result = @_last_operation_result
1148
+ @_last_operation_result = nil
1138
1149
  return nil unless result.is_a?(Hash)
1139
1150
  wire = (self.field_map[field.to_sym] || field).to_s
1140
1151
  result[wire]
@@ -1198,7 +1209,9 @@ module Parse
1198
1209
  def op_add_relation!(field, objects = [])
1199
1210
  objects = [objects] unless objects.is_a?(Array)
1200
1211
  return false if objects.empty?
1201
- relation_action = Parse::RelationAction.new(field, polarity: true, objects: objects)
1212
+ # The operation is keyed by the remote column, as a save sends it.
1213
+ remote_field = self.field_map[field.to_sym] || field
1214
+ relation_action = Parse::RelationAction.new(remote_field, polarity: true, objects: objects)
1202
1215
  operate_field! field, relation_action
1203
1216
  end
1204
1217
 
@@ -1210,7 +1223,9 @@ module Parse
1210
1223
  def op_remove_relation!(field, objects = [])
1211
1224
  objects = [objects] unless objects.is_a?(Array)
1212
1225
  return false if objects.empty?
1213
- relation_action = Parse::RelationAction.new(field, polarity: false, objects: objects)
1226
+ # The operation is keyed by the remote column, as a save sends it.
1227
+ remote_field = self.field_map[field.to_sym] || field
1228
+ relation_action = Parse::RelationAction.new(remote_field, polarity: false, objects: objects)
1214
1229
  operate_field! field, relation_action
1215
1230
  end
1216
1231
 
@@ -55,6 +55,9 @@ module Parse
55
55
  # @param preserve_changes [Boolean] if true, re-apply local dirty values to fetched fields.
56
56
  # By default (false), fetched fields accept server values and local changes are discarded.
57
57
  # Unfetched fields always preserve their dirty state regardless of this setting.
58
+ # A relation's staged additions and removals are kept in both modes (they are
59
+ # operations, not field values) and the relation stays marked changed, so the
60
+ # next save sends them. Use {Parse::Object#reload!} or `clear_changes!` to drop them.
58
61
  # @param opts [Hash] a set of options to pass to the client request.
59
62
  # @option opts [Boolean, Symbol] :cache (:write_only) caching mode:
60
63
  # - :write_only (default) - skip cache read, but update cache with fresh data
@@ -220,11 +223,13 @@ module Parse
220
223
  respond_to?(:record_authorization_hydration!)
221
224
 
222
225
  begin
223
- clear_changes!
226
+ # Not clear_changes!: that also drops a relation's staged
227
+ # additions and removals, which a fetch must keep.
228
+ clear_dirty_tracking!
224
229
  rescue => e
225
230
  # Log the error for debugging purposes
226
231
  warn "[Parse::Fetch] Warning: clear_changes! failed: #{e.class}: #{e.message}"
227
- # If clear_changes! fails, manually reset change tracking
232
+ # If clearing fails, manually reset change tracking
228
233
  @changed_attributes = {} if instance_variable_defined?(:@changed_attributes)
229
234
  @mutations_from_database = nil if instance_variable_defined?(:@mutations_from_database)
230
235
  @mutations_before_last_save = nil if instance_variable_defined?(:@mutations_before_last_save)
@@ -266,9 +271,31 @@ module Parse
266
271
  end
267
272
  end
268
273
 
274
+ # A relation whose proxy still holds staged additions or removals
275
+ # must stay dirty, or the next save sends nothing. The loop above
276
+ # cannot guarantee it: when the response carries the relation's
277
+ # descriptor, re-applying the same proxy is not a change.
278
+ remark_staged_relations_dirty!
279
+
269
280
  self
270
281
  end
271
282
 
283
+ # @!visibility private
284
+ # Marks dirty every relation whose proxy holds staged additions or
285
+ # removals. Used after a fetch has cleared the record's dirty tracking.
286
+ def remark_staged_relations_dirty!
287
+ return unless respond_to?(:relations)
288
+ relations.each_key do |key|
289
+ ivar = :"@#{key}"
290
+ next unless instance_variable_defined?(ivar)
291
+ proxy = instance_variable_get(ivar)
292
+ next unless proxy.respond_to?(:staged_changes?) && proxy.staged_changes?
293
+ next if changed.include?(key.to_s)
294
+ will_change_method = "#{key}_will_change!"
295
+ send(will_change_method) if respond_to?(will_change_method)
296
+ end
297
+ end
298
+
272
299
  # Fetches the object with explicit caching enabled.
273
300
  # This is a convenience method that calls fetch! with cache: true.
274
301
  # Use this when you want to leverage cached responses for better performance.
@@ -197,20 +197,28 @@ module Parse
197
197
 
198
198
  if respond_to?(:relations) && relations[field_sym]
199
199
  proxy = public_send(field_sym)
200
- # Reset the pending add/remove ledger that backs
201
- # relation_change_operations. The proxy itself has no public reset
202
- # API for these (its rollback!/restore_attributes path expects
203
- # setters that don't exist for additions/removals), so we clear
204
- # them directly and then drop the proxy's dirty markers.
205
- proxy.instance_variable_set(:@additions, []) if proxy.instance_variable_defined?(:@additions)
206
- proxy.instance_variable_set(:@removals, []) if proxy.instance_variable_defined?(:@removals)
200
+ # Drop the pending add/remove ledger that backs
201
+ # relation_change_operations, and the proxy's dirty markers. The
202
+ # relation proxy's clear_changes! also reloads its list on next
203
+ # access, since the loaded list included the discarded items. A
204
+ # handler that reads the relation after the revert therefore
205
+ # triggers one query for the stored members (on a saved owner).
207
206
  proxy.clear_changes! if proxy.respond_to?(:clear_changes!)
208
207
  clear_attribute_changes([field_str])
209
208
  return
210
209
  end
211
210
 
212
211
  if is_new
213
- public_send("#{field_str}=", nil)
212
+ if field_sym == :acl && !self.class.builtin_acl_default_active?
213
+ # The class's ACL policy still decides a new record's ACL: put
214
+ # back the init-time default stamp and let the save-time
215
+ # resolver run, instead of a nil ACL that a reply would send
216
+ # as `{}` (master-key only).
217
+ public_send(:acl=, self.class.default_acls.as_json)
218
+ @_acl_pristine = true
219
+ else
220
+ public_send("#{field_str}=", nil)
221
+ end
214
222
  else
215
223
  restore_attributes([field_str])
216
224
  end
@@ -1614,8 +1614,54 @@ module Parse
1614
1614
  clear_changes!
1615
1615
  end
1616
1616
 
1617
- # clears all dirty tracking information
1617
+ # clears all dirty tracking information. Array and relation proxies are
1618
+ # cleared too, so a relation's staged additions and removals are dropped
1619
+ # rather than sent by a later save.
1618
1620
  def clear_changes!
1621
+ clear_proxy_changes!
1622
+ clear_dirty_tracking!
1623
+ end
1624
+
1625
+ # @!visibility private
1626
+ # Instance variable names of the array and relation proxies a class can
1627
+ # hold. Cached per class because {#clear_changes!} runs for every record
1628
+ # built with an id (every query row). The cache is rebuilt when a field
1629
+ # or relation is declared after it was computed.
1630
+ # @return [Array<Symbol>]
1631
+ def self.proxy_change_ivars
1632
+ all_fields = fields
1633
+ all_relations = relations
1634
+ signature = [all_fields.size, all_relations.size]
1635
+ return @_proxy_change_ivars if @_proxy_change_ivars_signature == signature
1636
+ ivars = (all_fields.select { |_, type| type == :array }.keys + all_relations.keys)
1637
+ .uniq.map { |key| :"@#{key}" }.freeze
1638
+ # Assign the list before its signature so a concurrent reader that
1639
+ # sees the new signature also sees the new list.
1640
+ @_proxy_change_ivars = ivars
1641
+ @_proxy_change_ivars_signature = signature
1642
+ ivars
1643
+ end
1644
+
1645
+ # @!visibility private
1646
+ # Clears the dirty state of every array and relation proxy this record
1647
+ # holds, dropping a relation's staged additions and removals.
1648
+ def clear_proxy_changes!
1649
+ ivars = self.class.proxy_change_ivars
1650
+ return if ivars.empty?
1651
+ ivars.each do |ivar|
1652
+ next unless instance_variable_defined?(ivar)
1653
+ proxy = instance_variable_get(ivar)
1654
+ proxy.clear_changes! if proxy.respond_to?(:clear_changes!)
1655
+ end
1656
+ end
1657
+ private :clear_proxy_changes!
1658
+
1659
+ # @!visibility private
1660
+ # Clears the record's own dirty tracking but leaves array and relation
1661
+ # proxies alone. A fetch uses this: it replaces field values from the
1662
+ # server, but a relation's staged additions and removals are not field
1663
+ # values and must survive it (the fetch re-marks the field dirty).
1664
+ def clear_dirty_tracking!
1619
1665
  clear_changes_information
1620
1666
  # Clear the ACL snapshot used for proper acl_was tracking
1621
1667
  @_acl_snapshot_before_change = nil
@@ -1926,6 +1972,11 @@ module Parse
1926
1972
  # so a later edit captures a fresh baseline.
1927
1973
  @acl = Parse::ACL.typecast(snapshot.as_json, self) if snapshot && !@acl.nil?
1928
1974
  @_acl_snapshot_before_change = nil
1975
+ # A relation's staged additions and removals live on its proxy, not in
1976
+ # the attribute ActiveModel restores (after a fetch the restored value
1977
+ # is the proxy with the operations still staged). Drop them so a later
1978
+ # save does not send what was rolled back.
1979
+ clear_proxy_changes!
1929
1980
  end
1930
1981
 
1931
1982
  # Keys that mass assignment never applies to an object: the objectId
@@ -352,7 +352,17 @@ module Parse
352
352
  if @channels.is_a?(Array) && @channels.empty? == false
353
353
  q.where :channels.in => @channels
354
354
  end
355
- msg[:where] = q.compile_where unless q.where.empty?
355
+ unless q.where.empty?
356
+ compiled = q.compile_rest_where
357
+ # Constraints that compile to nothing would target every
358
+ # installation; refuse instead of widening the audience.
359
+ if compiled.empty?
360
+ raise ArgumentError,
361
+ "The push targeting query has constraints but compiles to no `where`, " \
362
+ "which would reach every installation. Check the targeting constraints."
363
+ end
364
+ msg[:where] = compiled
365
+ end
356
366
  elsif @channels.is_a?(Array) && @channels.empty? == false
357
367
  msg[:channels] = @channels
358
368
  end