parse-stack-next 5.7.6 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +830 -0
  3. data/README.md +14 -4
  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/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +317 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +225 -8
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. 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).
@@ -511,13 +561,13 @@ module Parse
511
561
  end
512
562
 
513
563
  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
564
+ error_code = error_response&.code.to_i
565
+ if error_code == TRANSACTION_CONFLICT_CODE && attempts < retries
517
566
  sleep(0.1 * attempts)
518
- retry
567
+ next
519
568
  end
520
- raise
569
+ detail = error_response&.error || "unknown error"
570
+ raise Parse::Error, "Transaction failed (code #{error_code}): #{detail}"
521
571
  end
522
572
  rescue StandardError
523
573
  original_states.each_value do |state|
@@ -989,59 +1039,60 @@ module Parse
989
1039
  iterator_block = nil
990
1040
  if block_given?
991
1041
  iterator_block = block
992
- force ||= false
993
1042
  else
994
1043
  # if no block given, assume you want to just save all objects
995
1044
  # regardless of modification.
996
1045
  force = true
997
1046
  end
998
- # Only generate the comparison block once.
999
- # updated_comparison_block = Proc.new { |x| x.updated_at }
1000
1047
 
1048
+ # Work on a copy so the caller's constraints hash is not mutated.
1049
+ base_constraints = constraints.dup
1001
1050
  anchor_date = Parse::Date.now
1002
- constraints.merge! :updated_at.on_or_before => anchor_date
1003
- constraints.merge! cache: false
1051
+ base_constraints[:updated_at.on_or_before] = anchor_date
1052
+ base_constraints[:cache] = false
1004
1053
  # 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
1054
+ base_constraints[:order] = :updated_at.asc
1055
+ base_constraints[:limit] = batch_size
1056
+
1057
+ # Keyset cursor over (updated_at, objectId). Saved objects move past
1058
+ # the anchor date and drop out of the query. Objects the block left
1059
+ # unchanged keep their updated_at, so each page resumes at the last
1060
+ # seen updated_at and excludes the ids already visited at exactly
1061
+ # that timestamp. Every page therefore makes progress, and records
1062
+ # the block does not modify are visited once instead of ending the
1063
+ # run early.
1064
+ cursor_time = nil
1065
+ seen_at_cursor = []
1009
1066
  has_errors = false
1010
1067
  loop do
1011
- results = update_query.results
1068
+ page_constraints = base_constraints.dup
1069
+ if cursor_time
1070
+ page_constraints[:updated_at.gte] = cursor_time
1071
+ page_constraints[:objectId.nin] = seen_at_cursor if seen_at_cursor.any?
1072
+ end
1073
+ results = query(page_constraints).results
1012
1074
 
1013
1075
  break if results.empty?
1014
1076
 
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
1077
  results.each(&iterator_block) if iterator_block.present?
1023
1078
  # we don't need to refresh the objects in the array with the results
1024
1079
  # since we will be throwing them away. Force determines whether
1025
1080
  # to save these objects regardless of whether they are dirty.
1026
1081
  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}"
1082
+ # Record failures before any break, so a failing last page is
1083
+ # reported.
1084
+ has_errors ||= batch.error?
1033
1085
 
1034
1086
  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
1087
 
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
1088
+ last_time = results.last.updated_at
1089
+ break if last_time.nil?
1090
+ if cursor_time && last_time == cursor_time
1091
+ seen_at_cursor.concat(results.map(&:id))
1092
+ else
1093
+ cursor_time = last_time
1094
+ seen_at_cursor = results.select { |r| r.updated_at == last_time }.map(&:id)
1042
1095
  end
1043
-
1044
- has_errors ||= batch.error?
1045
1096
  end
1046
1097
  not has_errors
1047
1098
  end
@@ -1073,9 +1124,22 @@ module Parse
1073
1124
  if response.error?
1074
1125
  puts "[#{parse_class}:#{field} Operation] #{response.error}"
1075
1126
  end
1127
+ # Keep the server's reply so a caller can adopt the field's new value
1128
+ # (Parse Server returns the whole array for Add/AddUnique/Remove).
1129
+ @_last_operation_result = response.success? && response.result.is_a?(Hash) ? response.result : nil
1076
1130
  response.success?
1077
1131
  end
1078
1132
 
1133
+ # @!visibility private
1134
+ # The value Parse Server returned for `field` from the last
1135
+ # {#operate_field!}, or nil when the reply did not include it.
1136
+ def _last_operation_value(field)
1137
+ result = @_last_operation_result
1138
+ return nil unless result.is_a?(Hash)
1139
+ wire = (self.field_map[field.to_sym] || field).to_s
1140
+ result[wire]
1141
+ end
1142
+
1079
1143
  # Perform an atomic add operation to the array field.
1080
1144
  # @param field [String] the name of the field in the Parse collection.
1081
1145
  # @param objects [Array] the set of items to add to this field.
@@ -1150,20 +1214,27 @@ module Parse
1150
1214
  operate_field! field, relation_action
1151
1215
  end
1152
1216
 
1153
- # Atomically increment or decrement a specific field.
1217
+ # Atomically increment or decrement a specific field. This is the one
1218
+ # place the local value is updated after the server applies the
1219
+ # increment; the `<field>_increment!` helpers delegate here.
1154
1220
  # @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.
1221
+ # @param amount [Numeric] the amount to increment. Use negative values to decrement.
1222
+ # Integer amounts are sent as integers; any other Numeric is sent as a Float
1223
+ # so fractional increments on Number columns are not truncated.
1156
1224
  # @see #operate_field!
1157
1225
  def op_increment!(field, amount = 1)
1158
1226
  unless amount.is_a?(Numeric)
1159
1227
  raise ArgumentError, "Amount should be numeric"
1160
1228
  end
1161
- result = operate_field! field, { __op: :Increment, amount: amount.to_i }.freeze
1229
+ amount = amount.is_a?(Integer) ? amount : amount.to_f
1230
+ result = operate_field! field, { __op: :Increment, amount: amount }.freeze
1162
1231
  if result
1163
- # Also update the local state to reflect the increment
1232
+ # Also update the local state to reflect the increment.
1233
+ # operate_field! does not touch local state.
1164
1234
  field_sym = field.to_sym
1165
1235
  current_value = self[field_sym] || 0
1166
- new_value = current_value + amount.to_i
1236
+ current_value = 0 unless current_value.is_a?(Numeric)
1237
+ new_value = current_value + amount
1167
1238
  set_attribute_method = "#{field}_set_attribute!"
1168
1239
  if respond_to?(set_attribute_method)
1169
1240
  send(set_attribute_method, new_value, true) # Set new value with dirty tracking
@@ -1190,19 +1261,47 @@ module Parse
1190
1261
 
1191
1262
  # Creates an array of all possible operations that need to be performed
1192
1263
  # on this object. This includes all property and relational operation changes.
1264
+ #
1265
+ # This is the path batch saves ({Array#save}) and
1266
+ # {Parse::Object.transaction} use, so it applies the same save-time
1267
+ # rules as {#save} that do not depend on callbacks:
1268
+ # - a new object resolves its {Parse::Object.acl_policy} owner ACL
1269
+ # first, so batch-created records get the same ACL as single saves;
1270
+ # - a new object sends its relation additions in the create body,
1271
+ # since there is no objectId yet for a separate relation PUT;
1272
+ # - a reference to an unsaved object raises {Parse::RecordNotSaved}
1273
+ # instead of writing a pointer with a null objectId.
1193
1274
  # @param force [Boolean] whether this object should be saved even if does not have
1194
1275
  # pending changes.
1195
1276
  # @return [Array<Parse::Request>] the list of API requests.
1277
+ # @raise [Parse::RecordNotSaved] if a pointer, array or relation field
1278
+ # references an object that has not been saved.
1196
1279
  def change_requests(force = false)
1197
1280
  requests = []
1198
1281
  # get the URI path for this object.
1199
1282
  uri = self.uri_path
1283
+ creating = new?
1284
+
1285
+ _resolve_default_acl if creating && respond_to?(:_resolve_default_acl, true)
1286
+ _assert_no_unsaved_references!
1287
+
1288
+ if creating
1289
+ body = attribute_updates
1290
+ relation_additions = relation_changes? ? relation_change_operations.first : {}
1291
+ if attribute_changes? || relation_additions.present? || force
1292
+ body.merge!(relation_additions)
1293
+ # Forward a client-assigned objectId, as {#create} does.
1294
+ body[Parse::Model::OBJECT_ID] = @id if @id.present?
1295
+ r = Request.new(:post, uri, body: body)
1296
+ r.tag = object_id
1297
+ requests << r
1298
+ end
1299
+ return requests
1300
+ end
1200
1301
 
1201
1302
  # generate the request to update the object (PUT)
1202
1303
  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)
1304
+ r = Request.new(:put, uri, body: attribute_updates)
1206
1305
  r.tag = object_id
1207
1306
  requests << r
1208
1307
  end
@@ -1220,6 +1319,52 @@ module Parse
1220
1319
  requests
1221
1320
  end
1222
1321
 
1322
+ # @!visibility private
1323
+ # Names of changed fields that reference a Parse object without an
1324
+ # objectId: a pointer field, an array of objects, or relation
1325
+ # additions. Saving such a field would store a pointer whose objectId
1326
+ # is null.
1327
+ # @return [Array<String>]
1328
+ def unsaved_reference_fields
1329
+ unsaved = ->(v) { v.is_a?(Parse::Pointer) && v.id.blank? }
1330
+ changed.each_with_object([]) do |key, list|
1331
+ sym = key.to_sym
1332
+ next unless fields[sym].present? || relations[sym].present?
1333
+ value = instance_variable_get(:"@#{key}")
1334
+ bad = if relations[sym].present?
1335
+ value.respond_to?(:additions) && value.additions.any?(&unsaved)
1336
+ elsif value.is_a?(Parse::CollectionProxy) || value.is_a?(Array)
1337
+ value.to_a.any?(&unsaved)
1338
+ else
1339
+ unsaved.call(value)
1340
+ end
1341
+ list << key.to_s if bad
1342
+ end
1343
+ end
1344
+
1345
+ # @!visibility private
1346
+ # @raise [Parse::RecordNotSaved] when {#unsaved_reference_fields} is not empty.
1347
+ def _assert_no_unsaved_references!
1348
+ fields_list = unsaved_reference_fields
1349
+ return if fields_list.empty?
1350
+ raise Parse::RecordNotSaved.new(self),
1351
+ "#{parse_class} references unsaved object(s) in #{fields_list.join(", ")}. " \
1352
+ "Save the referenced object(s) first."
1353
+ end
1354
+
1355
+ # @!visibility private
1356
+ # Refuses a write that would store a dangling pointer. Adds an error to
1357
+ # {#errors} and returns false (save turns that into false, or raises
1358
+ # with autoraise).
1359
+ def _check_unsaved_references
1360
+ fields_list = unsaved_reference_fields
1361
+ return true if fields_list.empty?
1362
+ fields_list.each do |f|
1363
+ errors.add(f.to_sym, "references an unsaved object; save it first")
1364
+ end
1365
+ false
1366
+ end
1367
+
1223
1368
  # This methods sends an update request for this object with the any change
1224
1369
  # information based on its local attributes. The bang implies that it will send
1225
1370
  # the request even though it is possible no changes were performed. This is useful
@@ -1242,6 +1387,7 @@ module Parse
1242
1387
  self.updated_at_will_change! if respond_to?(:updated_at_will_change!)
1243
1388
  end
1244
1389
  end
1390
+ return false unless _check_unsaved_references
1245
1391
  response = client.update_object(parse_class, id, attribute_updates, session_token: _session_token)
1246
1392
  @_last_response = response
1247
1393
  if response.success?
@@ -1279,6 +1425,9 @@ module Parse
1279
1425
  # @return [Boolean] true/false whether it was successful.
1280
1426
  def create
1281
1427
  run_callbacks :create do
1428
+ # Checked after the before_save / before_create callbacks so a
1429
+ # callback that saves the referenced object first still works.
1430
+ next false unless _check_unsaved_references
1282
1431
  body = attribute_updates
1283
1432
  # Forward a client-assigned objectId when a `before_create` callback
1284
1433
  # set it (e.g. `parse_reference precompute: true`). attribute_updates
@@ -1293,9 +1442,14 @@ module Parse
1293
1442
  unless res.error?
1294
1443
  result = res.result
1295
1444
  @id = result[Parse::Model::OBJECT_ID] || @id
1296
- @created_at = result["createdAt"] || @created_at
1445
+ # Store Parse::Date values, not the raw ISO strings. The :date
1446
+ # property reader converts a String on read and marks the field
1447
+ # dirty while doing so, which made a freshly created record
1448
+ # report unsaved changes (and send an empty PUT on the next
1449
+ # save) as soon as `updated_at` was read.
1450
+ @created_at = _server_date(result["createdAt"]) || @created_at
1297
1451
  #if the object is created, updatedAt == createdAt
1298
- @updated_at = result["updatedAt"] || result["createdAt"] || @updated_at
1452
+ @updated_at = _server_date(result["updatedAt"] || result["createdAt"]) || @updated_at
1299
1453
  # Because beforeSave hooks can change the fields we are saving, any items that were
1300
1454
  # changed, are returned to us and we should apply those locally to be in sync.
1301
1455
  set_attributes!(result)
@@ -1305,6 +1459,18 @@ module Parse
1305
1459
  end
1306
1460
  end
1307
1461
 
1462
+ # @!visibility private
1463
+ # Parse a server timestamp (ISO string or Parse date hash) into a
1464
+ # Parse::Date. Returns nil for nil or unparseable input.
1465
+ def _server_date(value)
1466
+ return nil if value.nil?
1467
+ return value if value.is_a?(Parse::Date)
1468
+ value = value["iso"] || value[:iso] if value.is_a?(Hash)
1469
+ Parse::Date.parse(value.to_s)
1470
+ rescue ArgumentError
1471
+ nil
1472
+ end
1473
+
1308
1474
  # @!visibility private
1309
1475
  def _session_token
1310
1476
  if @_session_token.respond_to?(:session_token)
@@ -1351,9 +1517,30 @@ module Parse
1351
1517
  raise Parse::Error::ProtocolError, error_msg
1352
1518
  end
1353
1519
 
1520
+ # A destroyed record is never recreated by a later save (ActiveRecord
1521
+ # returns false here too). Build a new object to write it again.
1522
+ if destroyed?
1523
+ if self.class.raise_on_save_failure || autoraise.present?
1524
+ raise Parse::RecordNotSaved.new(self), "Cannot save a destroyed #{parse_class} (id '#{@id}')."
1525
+ end
1526
+ return false
1527
+ end
1528
+
1354
1529
  @_session_token = _validate_session_token! session, :save
1355
1530
  return true unless changed? || force
1356
1531
 
1532
+ # A create may assign the objectId client-side from a callback
1533
+ # (parse_reference precompute, acl_owner :self). Until the create
1534
+ # returns createdAt, persisted? must stay false for such an object.
1535
+ @_creating_record = true if @id.blank?
1536
+ _save_with_callbacks(autoraise: autoraise, force: force, validate: validate)
1537
+ ensure
1538
+ remove_instance_variable(:@_creating_record) if defined?(@_creating_record)
1539
+ end
1540
+
1541
+ # @!visibility private
1542
+ # Body of {#save} after the deleted-object, session and no-change checks.
1543
+ def _save_with_callbacks(autoraise:, force:, validate:)
1357
1544
  # Run validations (validation callbacks are now triggered by valid? method)
1358
1545
  # Pass context so `on: :create` and `on: :update` options work with callbacks
1359
1546
  if validate
@@ -1376,7 +1563,9 @@ module Parse
1376
1563
  callback_executed = true
1377
1564
  #first process the create/update action if any
1378
1565
  #then perform any relation changes that need to be performed
1379
- success = new? ? create : perform_update(force: force)
1566
+ was_new = new?
1567
+ success = was_new ? create : perform_update(force: force)
1568
+ saved_fields = changed.map(&:to_sym)
1380
1569
 
1381
1570
  # if the save was successful and we have relational changes
1382
1571
  # let's update send those next.
@@ -1388,13 +1577,13 @@ module Parse
1388
1577
  success = update_relations
1389
1578
  if success
1390
1579
  changes_applied!
1391
- clear_partial_fetch_state!
1580
+ _after_save_partial_fetch_state!(was_new, saved_fields)
1392
1581
  elsif self.class.raise_on_save_failure || autoraise.present?
1393
1582
  raise Parse::RecordNotSaved.new(self), "Failed updating relations. #{self.parse_class} partially saved."
1394
1583
  end
1395
1584
  else
1396
1585
  changes_applied!
1397
- clear_partial_fetch_state!
1586
+ _after_save_partial_fetch_state!(was_new, saved_fields)
1398
1587
  end
1399
1588
  elsif self.class.raise_on_save_failure || autoraise.present?
1400
1589
  raise Parse::RecordNotSaved.new(self), "Failed to create or save attributes. #{self.parse_class} was not saved."
@@ -1427,6 +1616,11 @@ module Parse
1427
1616
 
1428
1617
  # Delete this record from the Parse collection. Only valid if this object has an `id`.
1429
1618
  # This will run all the `destroy` callbacks.
1619
+ #
1620
+ # After a successful destroy the object keeps its `id`, {Parse::Object#destroyed?}
1621
+ # returns true and {Parse::Object#persisted?} returns false. Saving a destroyed
1622
+ # object is refused: {#save} returns false and {#save!} raises
1623
+ # {Parse::RecordNotSaved}, as in ActiveRecord.
1430
1624
  # @param session [String] a session token if you want to apply ACLs for a user in this operation.
1431
1625
  # @raise ArgumentError if a non-nil value is passed to `session` that doesn't provide a session token string.
1432
1626
  # @return [Boolean] whether the operation was successful.
@@ -1438,7 +1632,11 @@ module Parse
1438
1632
  res = client.delete_object parse_class, id, session_token: _session_token
1439
1633
  success = res.success?
1440
1634
  if success
1441
- @id = nil
1635
+ # Keep the objectId so `dom_id`, `to_key` and Turbo Stream
1636
+ # removals still identify the record, and mark it destroyed so
1637
+ # `persisted?` is false and a later `save` refuses rather than
1638
+ # recreating it.
1639
+ @_destroyed = true
1442
1640
  changes_applied!
1443
1641
  elsif self.class.raise_on_save_failure
1444
1642
  raise Parse::RecordNotSaved.new(self), "Failed to create or save attributes. #{self.parse_class} was not saved."
@@ -1553,20 +1751,24 @@ module Parse
1553
1751
  # Clears changes information on all collections (array and relations) and all
1554
1752
  # local attributes.
1555
1753
  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.
1754
+ # Settle the array and relation proxies that exist. Read the ivar
1755
+ # rather than the getter: the getter can autofetch a pointer or
1756
+ # build an empty proxy, and neither has changes to settle. A
1757
+ # relation proxy also drops its saved additions and removals here,
1758
+ # so a later save does not send them again.
1759
+ (fields(:array).keys + relations.keys).uniq.each do |key|
1760
+ ivar = :"@#{key}"
1761
+ next unless instance_variable_defined?(ivar)
1762
+ proxy = instance_variable_get(ivar)
1567
1763
  proxy.changes_applied! if proxy.respond_to?(:changes_applied!)
1568
1764
  end
1569
1765
  changes_applied
1766
+ # The saved ACL is the new baseline. Without this, the snapshot from
1767
+ # before the first edit survived the save, so `acl_was` kept
1768
+ # reporting the pre-save ACL: revoking a grant after saving it
1769
+ # compared equal to that stale snapshot, `acl_changed?` was false,
1770
+ # and the revocation was silently dropped while save returned true.
1771
+ @_acl_snapshot_before_change = nil
1570
1772
  end
1571
1773
  end
1572
1774
  end
@@ -89,6 +89,9 @@ module Parse
89
89
  if klass.nil?
90
90
  klass = ::Class.new(Parse::Object)
91
91
  Parse::Generated.const_set(parse_class_name, klass)
92
+ # The default parse_class comes from the Ruby name, which here is
93
+ # namespaced ("Parse::Generated::Post"). Bind the server class name.
94
+ klass.parse_class(className.to_s)
92
95
  end
93
96
  unless klass.is_a?(Class) && klass <= Parse::Object
94
97
  raise ArgumentError, "Resolved class #{klass.inspect} for #{className.inspect} is not a Parse::Object subclass"
@@ -106,22 +109,111 @@ module Parse
106
109
  class_fields = klass.field_map.values + [:className]
107
110
  fields.each do |field, type|
108
111
  field = field.to_sym
109
- key = field.to_s.underscore.to_sym
110
112
  next if base_fields.include?(field) || class_fields.include?(field)
113
+ next unless type.respond_to?(:[]) && type[:type].present?
111
114
 
112
- data_type = type[:type].downcase.to_sym
113
- if data_type == :pointer
114
- klass.belongs_to key, as: safe_target_class(type[:targetClass]), field: field
115
- elsif data_type == :relation
116
- klass.has_many key, through: :relation, as: safe_target_class(type[:targetClass]), field: field
117
- else
118
- klass.property key, data_type, field: field
115
+ data_type = type[:type].to_s.downcase.to_sym
116
+ # A model's field registry shares one namespace between Ruby
117
+ # names and server columns, so a column whose name another column
118
+ # already claimed as its Ruby name (`foo_bar` after `fooBar`)
119
+ # cannot be mapped. Skip it instead of aborting the whole build.
120
+ if klass.fields.key?(field) || klass.field_map.key?(field)
121
+ builder_warn "skipping column #{className}.#{field}: its name is already " \
122
+ "used by another column of the same class"
123
+ next
124
+ end
125
+ key = safe_accessor_name(klass, field, data_type)
126
+ next if key.nil?
127
+
128
+ begin
129
+ if data_type == :pointer
130
+ klass.belongs_to key, as: safe_target_class(type[:targetClass]), field: field
131
+ elsif data_type == :relation
132
+ klass.has_many key, through: :relation, as: safe_target_class(type[:targetClass]), field: field
133
+ else
134
+ # A renamed accessor must not get an alias under the column
135
+ # name, which is the method it was renamed to avoid.
136
+ opts = { field: field }
137
+ opts[:alias] = false if key.to_s != field.to_s.underscore
138
+ klass.property key, data_type, **opts
139
+ end
140
+ rescue StandardError, SystemStackError => e
141
+ builder_warn "skipping column #{className}.#{field}: #{e.class}: #{e.message}"
142
+ next
143
+ end
144
+ # Hydration from server JSON dispatches on `<column>_set_attribute!`.
145
+ # The association and property DSLs only alias that hook when the
146
+ # column name itself is free as a method, so add it for a renamed
147
+ # accessor (`class` -> `class_field`).
148
+ setter = :"#{field}_set_attribute!"
149
+ target = :"#{key}_set_attribute!"
150
+ if setter != target && !klass.method_defined?(setter) && klass.method_defined?(target)
151
+ klass.send(:alias_method, setter, target)
119
152
  end
120
153
  class_fields.push(field)
121
154
  end
122
155
  klass
123
156
  end
124
157
 
158
+ # Suffix appended to a column's Ruby accessor name when the natural
159
+ # (underscored) name is unusable.
160
+ RENAMED_ACCESSOR_SUFFIX = "_field"
161
+
162
+ # @!visibility private
163
+ # Chooses the Ruby accessor name for server column `field`. The natural
164
+ # name is `field.underscore`. It is replaced with `<name>_field` (then
165
+ # `<name>_field2`, ...) when it would shadow a method every
166
+ # Parse::Object relies on (`class`, `hash`, `save`, `send`,
167
+ # `object_id`, `changes`, ...), when a boolean column's class-level
168
+ # scope would shadow a class method (`freeze`, `name`), or when another
169
+ # column already claimed it (`fooBar` and `foo_bar` both underscore to
170
+ # `foo_bar`). The server column name is unchanged (it is kept with
171
+ # `field:`). Returns nil, after a warning, when no safe name exists.
172
+ # @return [Symbol, nil]
173
+ def self.safe_accessor_name(klass, field, data_type)
174
+ natural = field.to_s.underscore
175
+ unless natural.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
176
+ builder_warn "skipping column #{klass.parse_class}.#{field}: not a valid Ruby method name"
177
+ return nil
178
+ end
179
+ candidates = [natural, "#{natural}#{RENAMED_ACCESSOR_SUFFIX}"]
180
+ candidates.concat((2..9).map { |n| "#{natural}#{RENAMED_ACCESSOR_SUFFIX}#{n}" })
181
+ name = candidates.find { |c| !accessor_conflict?(klass, c.to_sym, data_type) }
182
+ if name.nil?
183
+ builder_warn "skipping column #{klass.parse_class}.#{field}: no free Ruby accessor name"
184
+ return nil
185
+ end
186
+ if name != natural
187
+ builder_warn "column #{klass.parse_class}.#{field} is exposed as ##{name} " \
188
+ "because ##{natural} would clash with an existing method or column"
189
+ end
190
+ name.to_sym
191
+ end
192
+
193
+ # @!visibility private
194
+ # Whether defining accessor `key` on `klass` would replace an existing
195
+ # method or reuse a name another column already maps to.
196
+ def self.accessor_conflict?(klass, key, data_type)
197
+ return true if klass.fields.key?(key) || klass.field_map.key?(key)
198
+ return true if klass.respond_to?(:relations) && klass.relations.key?(key)
199
+ instance_names = [key, :"#{key}=", :"#{key}_changed?", :"#{key}_was"]
200
+ instance_names << :"#{key}?" if data_type == :boolean
201
+ return true if instance_names.any? { |m| method_taken?(klass, m) }
202
+ # Boolean columns also get a class-level scope named after the key.
203
+ return true if data_type == :boolean && klass.respond_to?(key, true)
204
+ false
205
+ end
206
+
207
+ # @!visibility private
208
+ def self.method_taken?(klass, name)
209
+ klass.method_defined?(name) || klass.private_method_defined?(name)
210
+ end
211
+
212
+ # @!visibility private
213
+ def self.builder_warn(message)
214
+ warn "[Parse::Model::Builder] #{message}"
215
+ end
216
+
125
217
  # @!visibility private
126
218
  # Validates a server-returned `targetClass` string before forwarding
127
219
  # it to `belongs_to`/`has_many`. Returns `nil` for missing or