parse-stack-next 5.7.6 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -34,8 +34,10 @@ module Parse
34
34
  end
35
35
  hash_constraints = constraints(true)
36
36
 
37
- klass.save_all(hash_constraints, &block) if block_given?
38
- klass.save_all(hash_constraints)
37
+ # One call: with a block it saves what the block modifies; without one
38
+ # it force-saves every match. (Previously a block call also ran a
39
+ # second, forced pass over every matching record.)
40
+ klass.save_all(hash_constraints, &block)
39
41
  end
40
42
  end
41
43
 
@@ -99,6 +101,8 @@ module Parse
99
101
  # late for this: the public API documents mutating an object and adding it
100
102
  # afterwards.
101
103
  TRANSACTION_CONTEXT_KEY = :__parse_transaction_context__
104
+ # Parse Server error code for a transaction write conflict.
105
+ TRANSACTION_CONFLICT_CODE = 251
102
106
 
103
107
  # Distinguishes a property whose ivar did not exist from one explicitly
104
108
  # set to nil. Rollback removes the former instead of defining it as nil.
@@ -416,11 +420,22 @@ module Parse
416
420
  # [user1, user2] # Return array of objects to save
417
421
  # end
418
422
  #
419
- # @param retries [Integer] number of times to retry on transaction conflict (error 251)
423
+ # Transactions need MongoDB running as a replica set (or mongos) on
424
+ # the Parse Server side.
425
+ #
426
+ # @param retries [Integer] number of attempts on a transaction conflict
427
+ # (error 251), which Parse Server reports only after rolling back.
428
+ # @param retry_server_errors [Boolean] also resend after a bare HTTP 500
429
+ # from Parse Server. Parse Server answers both an aborted transaction
430
+ # and a failed commit this way, and a commit can fail after it was
431
+ # applied, so a resend can apply the transaction twice. Off by
432
+ # default; enable it only for writes that are safe to repeat (it
433
+ # works around Parse Server running a transaction's requests
434
+ # concurrently, which MongoDB intermittently rejects).
420
435
  # @yield [Parse::BatchOperation] the batch operation to add requests to
421
436
  # @return [Array<Parse::Response>] the responses from the transaction
422
437
  # @raise [Parse::Error] if the transaction fails
423
- def transaction(retries: 5, &block)
438
+ def transaction(retries: 5, retry_server_errors: false, &block)
424
439
  raise ArgumentError, "Block required for transaction" unless block_given?
425
440
 
426
441
  previous_context = Fiber[TRANSACTION_CONTEXT_KEY]
@@ -476,10 +491,45 @@ module Parse
476
491
  end
477
492
 
478
493
  # Submit with retry logic for transaction conflicts.
494
+ # Parse Server reports a write conflict inside a transaction as
495
+ # error code 251, after rolling it back. Retry on that structured
496
+ # code only (a per-request response code, or the code of the
497
+ # response an error carries).
479
498
  attempts = 0
480
- begin
499
+ loop do
481
500
  attempts += 1
482
- responses = batch.submit
501
+ begin
502
+ responses = batch.submit
503
+ rescue Parse::Error => e
504
+ # A conflict is recognized only from the structured Parse
505
+ # error code in the response, never from message text, so an
506
+ # unrelated "251" in an error message cannot trigger a resend.
507
+ conflict = e.respond_to?(:response) && e.response.respond_to?(:code) &&
508
+ e.response.code.to_i == TRANSACTION_CONFLICT_CODE
509
+ # A bare 500 does not say whether the transaction was applied:
510
+ # Parse Server answers an aborted transaction and a failed
511
+ # commit (which may have been applied) the same way, and a
512
+ # 502/503/504 comes from a gateway that may have timed out
513
+ # after the commit. Only a 251 conflict is a confirmed
514
+ # rollback; a 500 is resent only when the caller opted in.
515
+ server_500 = e.is_a?(Parse::Error::ServiceUnavailableError) && e.http_status == 500
516
+ if (conflict || (server_500 && retry_server_errors)) && attempts < retries
517
+ sleep(0.1 * attempts)
518
+ next
519
+ end
520
+ # Name the likely causes rather than surfacing only "Internal
521
+ # server error". The original error is the cause.
522
+ if server_500
523
+ raise Parse::Error,
524
+ "Transaction failed: Parse Server answered 500 (#{e.message}) after " \
525
+ "#{attempts} attempt(s). The transaction may or may not have been applied; " \
526
+ "check before resending. Parse Server transactions need MongoDB running as " \
527
+ "a replica set or mongos, and on a standalone server every transaction fails " \
528
+ "this way; otherwise see the Parse Server log. For a non-atomic batch, save " \
529
+ "the objects with Array#save instead."
530
+ end
531
+ raise
532
+ end
483
533
 
484
534
  if responses.all?(&:success?)
485
535
  # Match responses to objects using the request tag (Ruby object_id).
@@ -504,20 +554,26 @@ module Parse
504
554
 
505
555
  # Apply any additional attributes returned by beforeSave hooks.
506
556
  obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
507
- 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
508
564
  end
509
565
 
510
566
  return responses
511
567
  end
512
568
 
513
569
  error_response = responses.find { |response| !response.success? }
514
- raise Parse::Error, "Transaction failed: #{error_response.error}"
515
- rescue Parse::Error => e
516
- if e.message.include?("251") && attempts < retries
570
+ error_code = error_response&.code.to_i
571
+ if error_code == TRANSACTION_CONFLICT_CODE && attempts < retries
517
572
  sleep(0.1 * attempts)
518
- retry
573
+ next
519
574
  end
520
- raise
575
+ detail = error_response&.error || "unknown error"
576
+ raise Parse::Error, "Transaction failed (code #{error_code}): #{detail}"
521
577
  end
522
578
  rescue StandardError
523
579
  original_states.each_value do |state|
@@ -989,59 +1045,60 @@ module Parse
989
1045
  iterator_block = nil
990
1046
  if block_given?
991
1047
  iterator_block = block
992
- force ||= false
993
1048
  else
994
1049
  # if no block given, assume you want to just save all objects
995
1050
  # regardless of modification.
996
1051
  force = true
997
1052
  end
998
- # Only generate the comparison block once.
999
- # updated_comparison_block = Proc.new { |x| x.updated_at }
1000
1053
 
1054
+ # Work on a copy so the caller's constraints hash is not mutated.
1055
+ base_constraints = constraints.dup
1001
1056
  anchor_date = Parse::Date.now
1002
- constraints.merge! :updated_at.on_or_before => anchor_date
1003
- constraints.merge! cache: false
1057
+ base_constraints[:updated_at.on_or_before] = anchor_date
1058
+ base_constraints[:cache] = false
1004
1059
  # oldest first, so we create a reduction-cycle
1005
- constraints.merge! order: :updated_at.asc, limit: batch_size
1006
- update_query = query(constraints)
1007
- #puts "Setting Anchor Date: #{anchor_date}"
1008
- cursor = nil
1060
+ base_constraints[:order] = :updated_at.asc
1061
+ base_constraints[:limit] = batch_size
1062
+
1063
+ # Keyset cursor over (updated_at, objectId). Saved objects move past
1064
+ # the anchor date and drop out of the query. Objects the block left
1065
+ # unchanged keep their updated_at, so each page resumes at the last
1066
+ # seen updated_at and excludes the ids already visited at exactly
1067
+ # that timestamp. Every page therefore makes progress, and records
1068
+ # the block does not modify are visited once instead of ending the
1069
+ # run early.
1070
+ cursor_time = nil
1071
+ seen_at_cursor = []
1009
1072
  has_errors = false
1010
1073
  loop do
1011
- results = update_query.results
1074
+ page_constraints = base_constraints.dup
1075
+ if cursor_time
1076
+ page_constraints[:updated_at.gte] = cursor_time
1077
+ page_constraints[:objectId.nin] = seen_at_cursor if seen_at_cursor.any?
1078
+ end
1079
+ results = query(page_constraints).results
1012
1080
 
1013
1081
  break if results.empty?
1014
1082
 
1015
- # verify we didn't get duplicates fetches
1016
- if cursor.is_a?(Parse::Object) && results.any? { |x| x.id == cursor.id }
1017
- warn "[#{self}.save_all] Unbounded update detected with id #{cursor.id}."
1018
- has_errors = true
1019
- break cursor
1020
- end
1021
-
1022
1083
  results.each(&iterator_block) if iterator_block.present?
1023
1084
  # we don't need to refresh the objects in the array with the results
1024
1085
  # since we will be throwing them away. Force determines whether
1025
1086
  # to save these objects regardless of whether they are dirty.
1026
1087
  batch = results.save(merge: false, force: force)
1027
-
1028
- # faster version assuming sorting order wasn't messed up
1029
- cursor = results.last
1030
- # slower version, but more accurate
1031
- # cursor_item = results.max_by(&updated_comparison_block).updated_at
1032
- # puts "[Parse::SaveAll] Updated #{results.count} records updated <= #{cursor.updated_at}"
1088
+ # Record failures before any break, so a failing last page is
1089
+ # reported.
1090
+ has_errors ||= batch.error?
1033
1091
 
1034
1092
  break if results.count < batch_size # we didn't hit a cap on results.
1035
- if cursor.is_a?(Parse::Object)
1036
- update_query.where :updated_at.gte => cursor.updated_at
1037
1093
 
1038
- if cursor.updated_at.present? && cursor.updated_at > anchor_date
1039
- warn "[#{self}.save_all] Reached anchor date #{anchor_date} < #{cursor.updated_at}"
1040
- break cursor
1041
- end
1094
+ last_time = results.last.updated_at
1095
+ break if last_time.nil?
1096
+ if cursor_time && last_time == cursor_time
1097
+ seen_at_cursor.concat(results.map(&:id))
1098
+ else
1099
+ cursor_time = last_time
1100
+ seen_at_cursor = results.select { |r| r.updated_at == last_time }.map(&:id)
1042
1101
  end
1043
-
1044
- has_errors ||= batch.error?
1045
1102
  end
1046
1103
  not has_errors
1047
1104
  end
@@ -1057,6 +1114,8 @@ module Parse
1057
1114
  # @param op_hash [Hash] The operation hash. It may also be of type {Parse::RelationAction}.
1058
1115
  # @return [Boolean] whether the operation was successful.
1059
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
1060
1119
  field = field.to_sym
1061
1120
  field = self.field_map[field] || field
1062
1121
  if op_hash.is_a?(Parse::RelationAction)
@@ -1073,9 +1132,25 @@ module Parse
1073
1132
  if response.error?
1074
1133
  puts "[#{parse_class}:#{field} Operation] #{response.error}"
1075
1134
  end
1135
+ # Keep the server's reply so a caller can adopt the field's new value
1136
+ # (Parse Server returns the whole array for Add/AddUnique/Remove).
1137
+ @_last_operation_result = response.success? && response.result.is_a?(Hash) ? response.result : nil
1076
1138
  response.success?
1077
1139
  end
1078
1140
 
1141
+ # @!visibility private
1142
+ # The value Parse Server returned for `field` from the last
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.
1146
+ def _last_operation_value(field)
1147
+ result = @_last_operation_result
1148
+ @_last_operation_result = nil
1149
+ return nil unless result.is_a?(Hash)
1150
+ wire = (self.field_map[field.to_sym] || field).to_s
1151
+ result[wire]
1152
+ end
1153
+
1079
1154
  # Perform an atomic add operation to the array field.
1080
1155
  # @param field [String] the name of the field in the Parse collection.
1081
1156
  # @param objects [Array] the set of items to add to this field.
@@ -1134,7 +1209,9 @@ module Parse
1134
1209
  def op_add_relation!(field, objects = [])
1135
1210
  objects = [objects] unless objects.is_a?(Array)
1136
1211
  return false if objects.empty?
1137
- 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)
1138
1215
  operate_field! field, relation_action
1139
1216
  end
1140
1217
 
@@ -1146,24 +1223,33 @@ module Parse
1146
1223
  def op_remove_relation!(field, objects = [])
1147
1224
  objects = [objects] unless objects.is_a?(Array)
1148
1225
  return false if objects.empty?
1149
- 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)
1150
1229
  operate_field! field, relation_action
1151
1230
  end
1152
1231
 
1153
- # Atomically increment or decrement a specific field.
1232
+ # Atomically increment or decrement a specific field. This is the one
1233
+ # place the local value is updated after the server applies the
1234
+ # increment; the `<field>_increment!` helpers delegate here.
1154
1235
  # @param field [String] the name of the field in the Parse collection.
1155
- # @param amount [Integer] the amoun to increment. Use negative values to decrement.
1236
+ # @param amount [Numeric] the amount to increment. Use negative values to decrement.
1237
+ # Integer amounts are sent as integers; any other Numeric is sent as a Float
1238
+ # so fractional increments on Number columns are not truncated.
1156
1239
  # @see #operate_field!
1157
1240
  def op_increment!(field, amount = 1)
1158
1241
  unless amount.is_a?(Numeric)
1159
1242
  raise ArgumentError, "Amount should be numeric"
1160
1243
  end
1161
- result = operate_field! field, { __op: :Increment, amount: amount.to_i }.freeze
1244
+ amount = amount.is_a?(Integer) ? amount : amount.to_f
1245
+ result = operate_field! field, { __op: :Increment, amount: amount }.freeze
1162
1246
  if result
1163
- # Also update the local state to reflect the increment
1247
+ # Also update the local state to reflect the increment.
1248
+ # operate_field! does not touch local state.
1164
1249
  field_sym = field.to_sym
1165
1250
  current_value = self[field_sym] || 0
1166
- new_value = current_value + amount.to_i
1251
+ current_value = 0 unless current_value.is_a?(Numeric)
1252
+ new_value = current_value + amount
1167
1253
  set_attribute_method = "#{field}_set_attribute!"
1168
1254
  if respond_to?(set_attribute_method)
1169
1255
  send(set_attribute_method, new_value, true) # Set new value with dirty tracking
@@ -1190,19 +1276,47 @@ module Parse
1190
1276
 
1191
1277
  # Creates an array of all possible operations that need to be performed
1192
1278
  # on this object. This includes all property and relational operation changes.
1279
+ #
1280
+ # This is the path batch saves ({Array#save}) and
1281
+ # {Parse::Object.transaction} use, so it applies the same save-time
1282
+ # rules as {#save} that do not depend on callbacks:
1283
+ # - a new object resolves its {Parse::Object.acl_policy} owner ACL
1284
+ # first, so batch-created records get the same ACL as single saves;
1285
+ # - a new object sends its relation additions in the create body,
1286
+ # since there is no objectId yet for a separate relation PUT;
1287
+ # - a reference to an unsaved object raises {Parse::RecordNotSaved}
1288
+ # instead of writing a pointer with a null objectId.
1193
1289
  # @param force [Boolean] whether this object should be saved even if does not have
1194
1290
  # pending changes.
1195
1291
  # @return [Array<Parse::Request>] the list of API requests.
1292
+ # @raise [Parse::RecordNotSaved] if a pointer, array or relation field
1293
+ # references an object that has not been saved.
1196
1294
  def change_requests(force = false)
1197
1295
  requests = []
1198
1296
  # get the URI path for this object.
1199
1297
  uri = self.uri_path
1298
+ creating = new?
1299
+
1300
+ _resolve_default_acl if creating && respond_to?(:_resolve_default_acl, true)
1301
+ _assert_no_unsaved_references!
1302
+
1303
+ if creating
1304
+ body = attribute_updates
1305
+ relation_additions = relation_changes? ? relation_change_operations.first : {}
1306
+ if attribute_changes? || relation_additions.present? || force
1307
+ body.merge!(relation_additions)
1308
+ # Forward a client-assigned objectId, as {#create} does.
1309
+ body[Parse::Model::OBJECT_ID] = @id if @id.present?
1310
+ r = Request.new(:post, uri, body: body)
1311
+ r.tag = object_id
1312
+ requests << r
1313
+ end
1314
+ return requests
1315
+ end
1200
1316
 
1201
1317
  # generate the request to update the object (PUT)
1202
1318
  if attribute_changes? || force
1203
- # if it's new, then we should call :post for creating the object.
1204
- method = new? ? :post : :put
1205
- r = Request.new(method, uri, body: attribute_updates)
1319
+ r = Request.new(:put, uri, body: attribute_updates)
1206
1320
  r.tag = object_id
1207
1321
  requests << r
1208
1322
  end
@@ -1220,6 +1334,52 @@ module Parse
1220
1334
  requests
1221
1335
  end
1222
1336
 
1337
+ # @!visibility private
1338
+ # Names of changed fields that reference a Parse object without an
1339
+ # objectId: a pointer field, an array of objects, or relation
1340
+ # additions. Saving such a field would store a pointer whose objectId
1341
+ # is null.
1342
+ # @return [Array<String>]
1343
+ def unsaved_reference_fields
1344
+ unsaved = ->(v) { v.is_a?(Parse::Pointer) && v.id.blank? }
1345
+ changed.each_with_object([]) do |key, list|
1346
+ sym = key.to_sym
1347
+ next unless fields[sym].present? || relations[sym].present?
1348
+ value = instance_variable_get(:"@#{key}")
1349
+ bad = if relations[sym].present?
1350
+ value.respond_to?(:additions) && value.additions.any?(&unsaved)
1351
+ elsif value.is_a?(Parse::CollectionProxy) || value.is_a?(Array)
1352
+ value.to_a.any?(&unsaved)
1353
+ else
1354
+ unsaved.call(value)
1355
+ end
1356
+ list << key.to_s if bad
1357
+ end
1358
+ end
1359
+
1360
+ # @!visibility private
1361
+ # @raise [Parse::RecordNotSaved] when {#unsaved_reference_fields} is not empty.
1362
+ def _assert_no_unsaved_references!
1363
+ fields_list = unsaved_reference_fields
1364
+ return if fields_list.empty?
1365
+ raise Parse::RecordNotSaved.new(self),
1366
+ "#{parse_class} references unsaved object(s) in #{fields_list.join(", ")}. " \
1367
+ "Save the referenced object(s) first."
1368
+ end
1369
+
1370
+ # @!visibility private
1371
+ # Refuses a write that would store a dangling pointer. Adds an error to
1372
+ # {#errors} and returns false (save turns that into false, or raises
1373
+ # with autoraise).
1374
+ def _check_unsaved_references
1375
+ fields_list = unsaved_reference_fields
1376
+ return true if fields_list.empty?
1377
+ fields_list.each do |f|
1378
+ errors.add(f.to_sym, "references an unsaved object; save it first")
1379
+ end
1380
+ false
1381
+ end
1382
+
1223
1383
  # This methods sends an update request for this object with the any change
1224
1384
  # information based on its local attributes. The bang implies that it will send
1225
1385
  # the request even though it is possible no changes were performed. This is useful
@@ -1242,6 +1402,7 @@ module Parse
1242
1402
  self.updated_at_will_change! if respond_to?(:updated_at_will_change!)
1243
1403
  end
1244
1404
  end
1405
+ return false unless _check_unsaved_references
1245
1406
  response = client.update_object(parse_class, id, attribute_updates, session_token: _session_token)
1246
1407
  @_last_response = response
1247
1408
  if response.success?
@@ -1279,6 +1440,9 @@ module Parse
1279
1440
  # @return [Boolean] true/false whether it was successful.
1280
1441
  def create
1281
1442
  run_callbacks :create do
1443
+ # Checked after the before_save / before_create callbacks so a
1444
+ # callback that saves the referenced object first still works.
1445
+ next false unless _check_unsaved_references
1282
1446
  body = attribute_updates
1283
1447
  # Forward a client-assigned objectId when a `before_create` callback
1284
1448
  # set it (e.g. `parse_reference precompute: true`). attribute_updates
@@ -1293,9 +1457,14 @@ module Parse
1293
1457
  unless res.error?
1294
1458
  result = res.result
1295
1459
  @id = result[Parse::Model::OBJECT_ID] || @id
1296
- @created_at = result["createdAt"] || @created_at
1460
+ # Store Parse::Date values, not the raw ISO strings. The :date
1461
+ # property reader converts a String on read and marks the field
1462
+ # dirty while doing so, which made a freshly created record
1463
+ # report unsaved changes (and send an empty PUT on the next
1464
+ # save) as soon as `updated_at` was read.
1465
+ @created_at = _server_date(result["createdAt"]) || @created_at
1297
1466
  #if the object is created, updatedAt == createdAt
1298
- @updated_at = result["updatedAt"] || result["createdAt"] || @updated_at
1467
+ @updated_at = _server_date(result["updatedAt"] || result["createdAt"]) || @updated_at
1299
1468
  # Because beforeSave hooks can change the fields we are saving, any items that were
1300
1469
  # changed, are returned to us and we should apply those locally to be in sync.
1301
1470
  set_attributes!(result)
@@ -1305,6 +1474,18 @@ module Parse
1305
1474
  end
1306
1475
  end
1307
1476
 
1477
+ # @!visibility private
1478
+ # Parse a server timestamp (ISO string or Parse date hash) into a
1479
+ # Parse::Date. Returns nil for nil or unparseable input.
1480
+ def _server_date(value)
1481
+ return nil if value.nil?
1482
+ return value if value.is_a?(Parse::Date)
1483
+ value = value["iso"] || value[:iso] if value.is_a?(Hash)
1484
+ Parse::Date.parse(value.to_s)
1485
+ rescue ArgumentError
1486
+ nil
1487
+ end
1488
+
1308
1489
  # @!visibility private
1309
1490
  def _session_token
1310
1491
  if @_session_token.respond_to?(:session_token)
@@ -1351,9 +1532,30 @@ module Parse
1351
1532
  raise Parse::Error::ProtocolError, error_msg
1352
1533
  end
1353
1534
 
1535
+ # A destroyed record is never recreated by a later save (ActiveRecord
1536
+ # returns false here too). Build a new object to write it again.
1537
+ if destroyed?
1538
+ if self.class.raise_on_save_failure || autoraise.present?
1539
+ raise Parse::RecordNotSaved.new(self), "Cannot save a destroyed #{parse_class} (id '#{@id}')."
1540
+ end
1541
+ return false
1542
+ end
1543
+
1354
1544
  @_session_token = _validate_session_token! session, :save
1355
1545
  return true unless changed? || force
1356
1546
 
1547
+ # A create may assign the objectId client-side from a callback
1548
+ # (parse_reference precompute, acl_owner :self). Until the create
1549
+ # returns createdAt, persisted? must stay false for such an object.
1550
+ @_creating_record = true if @id.blank?
1551
+ _save_with_callbacks(autoraise: autoraise, force: force, validate: validate)
1552
+ ensure
1553
+ remove_instance_variable(:@_creating_record) if defined?(@_creating_record)
1554
+ end
1555
+
1556
+ # @!visibility private
1557
+ # Body of {#save} after the deleted-object, session and no-change checks.
1558
+ def _save_with_callbacks(autoraise:, force:, validate:)
1357
1559
  # Run validations (validation callbacks are now triggered by valid? method)
1358
1560
  # Pass context so `on: :create` and `on: :update` options work with callbacks
1359
1561
  if validate
@@ -1376,7 +1578,9 @@ module Parse
1376
1578
  callback_executed = true
1377
1579
  #first process the create/update action if any
1378
1580
  #then perform any relation changes that need to be performed
1379
- success = new? ? create : perform_update(force: force)
1581
+ was_new = new?
1582
+ success = was_new ? create : perform_update(force: force)
1583
+ saved_fields = changed.map(&:to_sym)
1380
1584
 
1381
1585
  # if the save was successful and we have relational changes
1382
1586
  # let's update send those next.
@@ -1388,13 +1592,13 @@ module Parse
1388
1592
  success = update_relations
1389
1593
  if success
1390
1594
  changes_applied!
1391
- clear_partial_fetch_state!
1595
+ _after_save_partial_fetch_state!(was_new, saved_fields)
1392
1596
  elsif self.class.raise_on_save_failure || autoraise.present?
1393
1597
  raise Parse::RecordNotSaved.new(self), "Failed updating relations. #{self.parse_class} partially saved."
1394
1598
  end
1395
1599
  else
1396
1600
  changes_applied!
1397
- clear_partial_fetch_state!
1601
+ _after_save_partial_fetch_state!(was_new, saved_fields)
1398
1602
  end
1399
1603
  elsif self.class.raise_on_save_failure || autoraise.present?
1400
1604
  raise Parse::RecordNotSaved.new(self), "Failed to create or save attributes. #{self.parse_class} was not saved."
@@ -1427,6 +1631,11 @@ module Parse
1427
1631
 
1428
1632
  # Delete this record from the Parse collection. Only valid if this object has an `id`.
1429
1633
  # This will run all the `destroy` callbacks.
1634
+ #
1635
+ # After a successful destroy the object keeps its `id`, {Parse::Object#destroyed?}
1636
+ # returns true and {Parse::Object#persisted?} returns false. Saving a destroyed
1637
+ # object is refused: {#save} returns false and {#save!} raises
1638
+ # {Parse::RecordNotSaved}, as in ActiveRecord.
1430
1639
  # @param session [String] a session token if you want to apply ACLs for a user in this operation.
1431
1640
  # @raise ArgumentError if a non-nil value is passed to `session` that doesn't provide a session token string.
1432
1641
  # @return [Boolean] whether the operation was successful.
@@ -1438,7 +1647,11 @@ module Parse
1438
1647
  res = client.delete_object parse_class, id, session_token: _session_token
1439
1648
  success = res.success?
1440
1649
  if success
1441
- @id = nil
1650
+ # Keep the objectId so `dom_id`, `to_key` and Turbo Stream
1651
+ # removals still identify the record, and mark it destroyed so
1652
+ # `persisted?` is false and a later `save` refuses rather than
1653
+ # recreating it.
1654
+ @_destroyed = true
1442
1655
  changes_applied!
1443
1656
  elsif self.class.raise_on_save_failure
1444
1657
  raise Parse::RecordNotSaved.new(self), "Failed to create or save attributes. #{self.parse_class} was not saved."
@@ -1553,20 +1766,24 @@ module Parse
1553
1766
  # Clears changes information on all collections (array and relations) and all
1554
1767
  # local attributes.
1555
1768
  def changes_applied!
1556
- # find all fields that are of type :array
1557
- fields(:array) do |key, v|
1558
- proxy = send(key)
1559
- # clear changes
1560
- proxy.changes_applied! if proxy.respond_to?(:changes_applied!)
1561
- end
1562
-
1563
- # for all relational fields,
1564
- relations.each do |key, v|
1565
- proxy = send(key)
1566
- # clear changes if they support the method.
1769
+ # Settle the array and relation proxies that exist. Read the ivar
1770
+ # rather than the getter: the getter can autofetch a pointer or
1771
+ # build an empty proxy, and neither has changes to settle. A
1772
+ # relation proxy also drops its saved additions and removals here,
1773
+ # so a later save does not send them again.
1774
+ (fields(:array).keys + relations.keys).uniq.each do |key|
1775
+ ivar = :"@#{key}"
1776
+ next unless instance_variable_defined?(ivar)
1777
+ proxy = instance_variable_get(ivar)
1567
1778
  proxy.changes_applied! if proxy.respond_to?(:changes_applied!)
1568
1779
  end
1569
1780
  changes_applied
1781
+ # The saved ACL is the new baseline. Without this, the snapshot from
1782
+ # before the first edit survived the save, so `acl_was` kept
1783
+ # reporting the pre-save ACL: revoking a grant after saving it
1784
+ # compared equal to that stale snapshot, `acl_changed?` was false,
1785
+ # and the revocation was silently dropped while save returned true.
1786
+ @_acl_snapshot_before_change = nil
1570
1787
  end
1571
1788
  end
1572
1789
  end