parse-stack-next 5.8.0 → 5.8.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. 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,235 @@ 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 that returns false cannot tell "already gone" from
255
+ # "denied", so it never resets and forgets nothing for a row the
256
+ # caller could not read; one that returns true removed the row, so
257
+ # the recorded owner (or the rate-limited reset) applies.
258
+ if result.nil?
259
+ _forget_identity!(token.is_a?(String) ? token : nil, nil)
260
+ else
261
+ deleted = result == true
262
+ _forget_identity!(token, owner_id, reset_fallback: deleted, deleted: deleted)
263
+ end
227
264
  end
228
- success
229
265
  end
230
266
 
267
+ # Seconds between two full identity-cache resets triggered by a failed
268
+ # session lookup on the same client.
269
+ RESET_INTERVAL = 5
270
+
271
+ # Process-local record of the last lookup-triggered reset per client.
272
+ @identity_reset_at = {}
273
+ @identity_reset_mutex = Mutex.new
274
+
275
+ class << self
276
+ # Record, in the identity context of `cl` (the client that fetched
277
+ # them), which user owns each session that carries both its objectId
278
+ # and its owner, so a later delete can drop the owner's cached
279
+ # identities after the row is gone. Never raises: it runs while query
280
+ # results are decoded.
281
+ # @param sessions [Array<Parse::Object>]
282
+ # @param cl [Parse::Client]
283
+ # @!visibility private
284
+ def _remember_owners!(sessions, cl)
285
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
286
+ return unless auth.respond_to?(:remember_session_owner)
287
+ Array(sessions).each do |o|
288
+ next unless o.is_a?(Parse::Session)
289
+ owner_id = o.send(:_identity_owner_id)
290
+ id = o.id
291
+ auth.remember_session_owner(id, owner_id) if owner_id && id.present?
292
+ end
293
+ nil
294
+ rescue StandardError
295
+ nil
296
+ end
297
+
298
+ private
299
+
300
+ # Reset `cl`'s identity and role caches, at most once every
301
+ # {RESET_INTERVAL} seconds per client.
302
+ # @!visibility private
303
+ def _rate_limited_identity_reset!(cl)
304
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
305
+ return unless auth.respond_to?(:reset_caches!)
306
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
307
+ due = @identity_reset_mutex.synchronize do
308
+ last = @identity_reset_at[cl.object_id]
309
+ next false if last && now - last < RESET_INTERVAL
310
+ @identity_reset_at[cl.object_id] = now
311
+ true
312
+ end
313
+ auth.reset_caches! if due
314
+ end
315
+
316
+ # Look up the token and owner of every session about to be deleted that
317
+ # does not carry them, so the delete can drop their identity entries.
318
+ # One `_Session` query per client. When the delete runs as a user
319
+ # (`session_token`), the lookup runs as that user too, so it only
320
+ # reads sessions that user can see. Otherwise a client with a master
321
+ # key reads it as SDK metadata (it works inside
322
+ # `Parse.without_master_key`), and one without skips the lookup. The
323
+ # query never uses the response cache: its rows carry live session
324
+ # tokens.
325
+ #
326
+ # A row the lookup did not return is marked absent: there is nothing
327
+ # to forget for it. A row a session-scoped lookup could not see is
328
+ # marked `:invisible`: a denied or not-found delete of it forgets
329
+ # nothing and never resets, so a user deleting session ids they cannot
330
+ # read cannot evict other users' cached identities. A delete of it
331
+ # that succeeds removed a session the caller was allowed to delete, so
332
+ # its recorded owner (or the rate-limited reset) is used. Only
333
+ # a lookup that raised marks its sessions `:unknown`, which makes
334
+ # their delete reset the client's identity cache (rate limited).
335
+ # @param sessions [Array<Parse::Object>]
336
+ # @param session_token [String, nil] the delete's own session.
337
+ # @!visibility private
338
+ def _preload_identity_for_destroy!(sessions, session_token: nil)
339
+ pending = sessions.select { |o| o.is_a?(Parse::Session) && o.send(:_identity_lookup_needed?) }
340
+ return if pending.empty?
341
+ pending.group_by(&:client).each do |cl, group|
342
+ has_master = cl.respond_to?(:master_key) && cl.master_key.present?
343
+ token = session_token.respond_to?(:session_token) ? session_token.session_token : session_token
344
+ token = nil unless token.is_a?(String) && !token.strip.empty?
345
+ unless has_master || token
346
+ group.each { |o| o.instance_variable_set(:@_identity_for_destroy, :absent) }
347
+ next
348
+ end
349
+ ids = group.map(&:id).uniq
350
+ scoped = !token.nil?
351
+ found = begin
352
+ query = Parse::Session.query(:objectId.in => ids, limit: ids.size)
353
+ query.keys(:session_token, :user)
354
+ query.client = cl
355
+ query.cache = false
356
+ if scoped
357
+ query.session_token = token
358
+ else
359
+ query.instance_variable_set(:@_metadata_master, true)
360
+ end
361
+ query.results.to_h do |row|
362
+ owner = row.instance_variable_get(:@user)
363
+ [row.id, [row.instance_variable_get(:@session_token), owner.respond_to?(:id) ? owner.id : nil]]
364
+ end
365
+ rescue StandardError => e
366
+ warn "[Parse::Session] could not look up the token and owner of " \
367
+ "#{ids.size} session(s) before deleting them (#{e.class}); " \
368
+ "dropping the whole identity cache instead."
369
+ nil
370
+ end
371
+ group.each do |o|
372
+ mark = if found.nil?
373
+ :unknown
374
+ elsif (row = found[o.id]) && (row[0].is_a?(String) || row[1])
375
+ row
376
+ elsif scoped
377
+ :invisible
378
+ else
379
+ :absent
380
+ end
381
+ o.instance_variable_set(:@_identity_for_destroy, mark)
382
+ end
383
+ end
384
+ end
385
+ end
386
+
387
+ # Called by `Array#destroy` for each session in the batch with its
388
+ # response. The entries are dropped only when the delete succeeded or
389
+ # reported the row gone ("object not found").
390
+ # @!visibility private
391
+ def _after_batch_destroy(response = nil)
392
+ identity = _identity_for_destroy
393
+ _clear_identity_for_destroy!
394
+ return unless Parse::Session.send(:_destroy_applied?, response)
395
+ deleted = response.nil? || (response.respond_to?(:success?) && response.success?)
396
+ # A row the caller could not read counts only when the delete itself
397
+ # succeeded: "object not found" there may mean "not yours".
398
+ return if identity[0] == :invisible && !deleted
399
+ # The batch response tells success and "object not found" apart from
400
+ # a denial, so an absent row whose owner was never recorded may fall
401
+ # back to the rate-limited reset here.
402
+ _forget_identity!(*identity, reset_fallback: true, deleted: deleted)
403
+ end
404
+ private :_after_batch_destroy
405
+
406
+ # Whether a batch delete response means the row is gone: success, or
407
+ # "object not found". A missing response (older callers) counts as
408
+ # applied.
409
+ # @!visibility private
410
+ def self._destroy_applied?(response)
411
+ return true if response.nil?
412
+ (response.respond_to?(:success?) && response.success?) ||
413
+ (response.respond_to?(:object_not_found?) && response.object_not_found?)
414
+ end
415
+ private_class_method :_destroy_applied?
416
+
417
+ # Remove the token and owner {._preload_identity_for_destroy!} looked
418
+ # up. The token is a live credential and must not outlive the delete
419
+ # (it would show in `inspect` or `instance_variables`).
420
+ # @!visibility private
421
+ def _clear_identity_for_destroy!
422
+ remove_instance_variable(:@_identity_for_destroy) if instance_variable_defined?(:@_identity_for_destroy)
423
+ end
424
+ private :_clear_identity_for_destroy!
425
+
426
+ # Whether this session lacks the token or the owner its identity-plane
427
+ # entries are keyed by: a fetch without the master key (no token) or
428
+ # with `keys:` leaving them out.
429
+ # @!visibility private
430
+ def _identity_lookup_needed?
431
+ # Keyed on the objectId, not `new?`: `Array#destroy` deletes by id
432
+ # alone, so a session built from an id (no timestamps loaded) is still
433
+ # deleted and its token must still be dropped.
434
+ return false if @id.blank?
435
+ !@session_token.is_a?(String) || @session_token.empty? || _identity_owner_id.nil?
436
+ end
437
+ private :_identity_lookup_needed?
438
+
231
439
  # Serialization omits `sessionToken` unless `include_session_token: true`
232
440
  # is passed. A session token is a bearer credential, and `as_json` is the
233
441
  # surface that reaches logs, API responses and agent tool output.
@@ -245,7 +453,87 @@ module Parse
245
453
  # Redacts the session token from the default inspect output.
246
454
  # @return [String]
247
455
  def inspect
248
- Parse::User.redact_session_token(super, @session_token)
456
+ looked_up = @_identity_for_destroy
457
+ Parse::User.redact_session_token(super, @session_token, looked_up.is_a?(Array) ? looked_up[0] : nil)
458
+ end
459
+
460
+ private
461
+
462
+ # The token and owner id to forget for this delete: what this instance
463
+ # carries, completed by {._preload_identity_for_destroy!} when it did
464
+ # not, or `:unknown` when that lookup failed.
465
+ # @!visibility private
466
+ def _identity_for_destroy
467
+ looked_up = @_identity_for_destroy
468
+ token = @session_token.is_a?(String) && !@session_token.empty? ? @session_token : nil
469
+ owner_id = _identity_owner_id
470
+ return [:unknown, owner_id] if looked_up == :unknown
471
+ # A session-scoped lookup could not see the row: forget what this
472
+ # instance carries; with nothing at all, mark it invisible so only a
473
+ # delete that succeeds may use the recorded owner or the reset.
474
+ return (token || owner_id ? [token, owner_id] : [:invisible, nil]) if looked_up == :invisible
475
+ if looked_up == :absent
476
+ # No row to read: the session is gone or not visible. Forget what
477
+ # this instance carries; with nothing at all, mark it absent so the
478
+ # delete can fall back to the recorded owner.
479
+ return token || owner_id ? [token, owner_id] : [:absent, nil]
480
+ end
481
+ if looked_up.is_a?(Array)
482
+ token ||= looked_up[0]
483
+ owner_id ||= looked_up[1]
484
+ end
485
+ [token, owner_id]
486
+ end
487
+
488
+ # The owning user's id, read from the association ivar directly:
489
+ # calling `user` on a partially fetched session could trigger an
490
+ # autofetch just to learn the owner.
491
+ # @!visibility private
492
+ def _identity_owner_id
493
+ owner = @user
494
+ owner.respond_to?(:id) ? owner.id : nil
495
+ end
496
+
497
+ # Drop the token and the owner's entries from this session's client's
498
+ # identity plane.
499
+ # @!visibility private
500
+ def _forget_identity!(token, owner_id, reset_fallback: false, deleted: false)
501
+ cl = client
502
+ if token == :invisible
503
+ # The caller could not read the row. Only a delete that succeeded
504
+ # touches other identities: the recorded owner, else the
505
+ # rate-limited reset. A denied or not-found delete forgets nothing.
506
+ return unless deleted
507
+ token = :absent
508
+ end
509
+ if token == :absent
510
+ # The row was not readable before the delete. Use the owner recorded
511
+ # when this session was last loaded; without one, and only when the
512
+ # delete is known to have applied or found the row gone, fall back to
513
+ # the rate-limited reset. Never on a denied delete.
514
+ token = nil
515
+ auth = cl.respond_to?(:authorization) ? cl.authorization : nil
516
+ recorded = auth.respond_to?(:session_owner) ? auth.session_owner(@id) : nil
517
+ if recorded
518
+ owner_id ||= recorded
519
+ elsif owner_id.nil? && reset_fallback
520
+ Parse::Session.send(:_rate_limited_identity_reset!, cl)
521
+ end
522
+ end
523
+ if token == :unknown
524
+ # The lookup failed, so the token's entry cannot be named. Drop every
525
+ # cached identity on this client (the next read of each token
526
+ # re-resolves it, which is safe), at most once every RESET_INTERVAL
527
+ # seconds so repeated failures cannot flush a shared plane on every
528
+ # request. The owner's entries go as well when the owner is known.
529
+ Parse::Session.send(:_rate_limited_identity_reset!, cl)
530
+ token = nil
531
+ end
532
+ cl.invalidate_session_identity(token) if token.is_a?(String) && cl.respond_to?(:invalidate_session_identity)
533
+ cl.invalidate_user_identity(owner_id) if owner_id && cl.respond_to?(:invalidate_user_identity)
534
+ rescue StandardError
535
+ # Runs from an `ensure`: never replace the delete's own outcome.
536
+ nil
249
537
  end
250
538
  end
251
539
  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.
@@ -432,11 +432,20 @@ module Parse
432
432
  # default; enable it only for writes that are safe to repeat (it
433
433
  # works around Parse Server running a transaction's requests
434
434
  # concurrently, which MongoDB intermittently rejects).
435
+ # @param session [String, #session_token, nil] run the transaction as
436
+ # this user (ACL and CLP enforced). Without it, the transaction runs
437
+ # with the credentials of its objects' class client, as a single
438
+ # save does.
435
439
  # @yield [Parse::BatchOperation] the batch operation to add requests to
436
440
  # @return [Array<Parse::Response>] the responses from the transaction
437
441
  # @raise [Parse::Error] if the transaction fails
438
- def transaction(retries: 5, retry_server_errors: false, &block)
442
+ # @raise [Parse::BatchOperation::MixedAuthorityError] if the objects
443
+ # are bound to clients with different credentials. Parse Server runs
444
+ # a transaction under one credential, so it is refused before
445
+ # anything is sent.
446
+ def transaction(retries: 5, retry_server_errors: false, session: nil, &block)
439
447
  raise ArgumentError, "Block required for transaction" unless block_given?
448
+ session_token = Parse::BatchOperation.session_token_for!(session)
440
449
 
441
450
  previous_context = Fiber[TRANSACTION_CONTEXT_KEY]
442
451
  transaction_context = { snapshots: {}, created_objects: {} }
@@ -490,6 +499,10 @@ module Parse
490
499
  result.each { |obj| batch_wrapper.add(obj) if obj.respond_to?(:change_requests) }
491
500
  end
492
501
 
502
+ if session_token
503
+ batch.requests.each { |r| r.opts[:session_token] = session_token if r.opts.is_a?(Hash) }
504
+ end
505
+
493
506
  # Submit with retry logic for transaction conflicts.
494
507
  # Parse Server reports a write conflict inside a transaction as
495
508
  # error code 251, after rolling it back. Retry on that structured
@@ -554,7 +567,13 @@ module Parse
554
567
 
555
568
  # Apply any additional attributes returned by beforeSave hooks.
556
569
  obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
557
- obj.send(:clear_changes!) if obj.respond_to?(:clear_changes!, true)
570
+ # The transaction carried the record's relation ops too, so
571
+ # settle them as a save does rather than discarding them.
572
+ if obj.respond_to?(:changes_applied!, true)
573
+ obj.send(:changes_applied!)
574
+ elsif obj.respond_to?(:clear_changes!, true)
575
+ obj.send(:clear_changes!)
576
+ end
558
577
  end
559
578
 
560
579
  return responses
@@ -1108,6 +1127,8 @@ module Parse
1108
1127
  # @param op_hash [Hash] The operation hash. It may also be of type {Parse::RelationAction}.
1109
1128
  # @return [Boolean] whether the operation was successful.
1110
1129
  def operate_field!(field, op_hash)
1130
+ # A reply from an earlier operation must never be read as this one's.
1131
+ @_last_operation_result = nil
1111
1132
  field = field.to_sym
1112
1133
  field = self.field_map[field] || field
1113
1134
  if op_hash.is_a?(Parse::RelationAction)
@@ -1132,9 +1153,12 @@ module Parse
1132
1153
 
1133
1154
  # @!visibility private
1134
1155
  # The value Parse Server returned for `field` from the last
1135
- # {#operate_field!}, or nil when the reply did not include it.
1156
+ # {#operate_field!}, or nil when the reply did not include it. The
1157
+ # reply is read once: it is forgotten here so a later read cannot
1158
+ # pick up a stale value.
1136
1159
  def _last_operation_value(field)
1137
1160
  result = @_last_operation_result
1161
+ @_last_operation_result = nil
1138
1162
  return nil unless result.is_a?(Hash)
1139
1163
  wire = (self.field_map[field.to_sym] || field).to_s
1140
1164
  result[wire]
@@ -1198,7 +1222,9 @@ module Parse
1198
1222
  def op_add_relation!(field, objects = [])
1199
1223
  objects = [objects] unless objects.is_a?(Array)
1200
1224
  return false if objects.empty?
1201
- relation_action = Parse::RelationAction.new(field, polarity: true, objects: objects)
1225
+ # The operation is keyed by the remote column, as a save sends it.
1226
+ remote_field = self.field_map[field.to_sym] || field
1227
+ relation_action = Parse::RelationAction.new(remote_field, polarity: true, objects: objects)
1202
1228
  operate_field! field, relation_action
1203
1229
  end
1204
1230
 
@@ -1210,7 +1236,9 @@ module Parse
1210
1236
  def op_remove_relation!(field, objects = [])
1211
1237
  objects = [objects] unless objects.is_a?(Array)
1212
1238
  return false if objects.empty?
1213
- relation_action = Parse::RelationAction.new(field, polarity: false, objects: objects)
1239
+ # The operation is keyed by the remote column, as a save sends it.
1240
+ remote_field = self.field_map[field.to_sym] || field
1241
+ relation_action = Parse::RelationAction.new(remote_field, polarity: false, objects: objects)
1214
1242
  operate_field! field, relation_action
1215
1243
  end
1216
1244
 
@@ -1251,6 +1279,9 @@ module Parse
1251
1279
  uri = self.uri_path
1252
1280
  r = Request.new(:delete, uri)
1253
1281
  r.tag = object_id
1282
+ # A batch sends this through the class's client (and its bound
1283
+ # session), as a single destroy does.
1284
+ r.client = client
1254
1285
  r
1255
1286
  end
1256
1287
 
@@ -1261,6 +1292,8 @@ module Parse
1261
1292
 
1262
1293
  # Creates an array of all possible operations that need to be performed
1263
1294
  # on this object. This includes all property and relational operation changes.
1295
+ # Each request carries this class's client, so a batch sends it with the
1296
+ # same credentials as a single {#save}.
1264
1297
  #
1265
1298
  # This is the path batch saves ({Array#save}) and
1266
1299
  # {Parse::Object.transaction} use, so it applies the same save-time
@@ -1294,6 +1327,7 @@ module Parse
1294
1327
  body[Parse::Model::OBJECT_ID] = @id if @id.present?
1295
1328
  r = Request.new(:post, uri, body: body)
1296
1329
  r.tag = object_id
1330
+ r.client = client
1297
1331
  requests << r
1298
1332
  end
1299
1333
  return requests
@@ -1303,6 +1337,7 @@ module Parse
1303
1337
  if attribute_changes? || force
1304
1338
  r = Request.new(:put, uri, body: attribute_updates)
1305
1339
  r.tag = object_id
1340
+ r.client = client
1306
1341
  requests << r
1307
1342
  end
1308
1343
 
@@ -1313,6 +1348,7 @@ module Parse
1313
1348
  next if ops.empty?
1314
1349
  r = Request.new(:put, uri, body: ops)
1315
1350
  r.tag = object_id
1351
+ r.client = client
1316
1352
  requests << r
1317
1353
  end
1318
1354
  end
@@ -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