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
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "digest"
5
+ require "securerandom"
5
6
  require "set"
6
7
 
7
8
  module Parse
@@ -163,6 +164,25 @@ module Parse
163
164
  value
164
165
  end
165
166
 
167
+ # A random marker replaced on every identity invalidation, read by
168
+ # `Parse::Authorization` before and after it caches a token so an
169
+ # invalidation from any process in between is detected. A nonce rather
170
+ # than a counter: a plane clear drops it, and a counter restarting at
171
+ # 0 could repeat a value an in-flight lookup captured.
172
+ # @return [String, nil] nil when never set or cleared.
173
+ def invalidation_nonce
174
+ value = @store[invalidation_nonce_key]
175
+ value.nil? ? nil : value.to_s
176
+ end
177
+
178
+ # Replace the invalidation nonce.
179
+ # @return [String] the new nonce.
180
+ def bump_invalidation_nonce
181
+ nonce = SecureRandom.hex(16)
182
+ @store.store(invalidation_nonce_key, nonce, {})
183
+ nonce
184
+ end
185
+
166
186
  # @return [Float] the last invalidation epoch, 0.0 when never set.
167
187
  def epoch
168
188
  @store[epoch_key].to_f
@@ -253,6 +273,12 @@ module Parse
253
273
  @keyspace.key(@family, "meta", "epoch")
254
274
  end
255
275
 
276
+ # Sits beside the epoch under the reserved `meta` segment; a token key
277
+ # is a digest, so no token can name it.
278
+ def invalidation_nonce_key
279
+ @keyspace.key(@family, "meta", "invalidation")
280
+ end
281
+
256
282
  # Generations live in the same family so a plane clear takes them with it,
257
283
  # and are namespaced under `gen:` so they cannot collide with an entry
258
284
  # whose logical key happens to be a user id.
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "faraday"
5
+ require "securerandom"
5
6
  require "active_support"
6
7
  require "active_support/core_ext"
7
8
 
@@ -15,6 +16,18 @@ module Parse
15
16
  include Parse::Protocol
16
17
  # @!visibility private
17
18
  DISABLE_MASTER_KEY = "X-Disable-Parse-Master-Key".freeze
19
+ # SDK-internal marker for a request that reads metadata (a class
20
+ # schema, the role graph) while enforcing a scope. Such a request keeps
21
+ # the master key inside {Parse.without_master_key}, which governs row
22
+ # access. The header carries a random per-process token and is removed
23
+ # before the request is logged or sent, so a header copied from user
24
+ # input cannot set it. {Parse::Client#request} adds it only for the
25
+ # `metadata_master:` request option set to
26
+ # {Parse::Client::METADATA_MASTER_REQUEST}.
27
+ # @!visibility private
28
+ METADATA_MASTER = "X-Parse-Stack-Metadata-Master".freeze
29
+ # @!visibility private
30
+ METADATA_MASTER_TOKEN = SecureRandom.hex(32).freeze
18
31
  # @return [String] the application id for this Parse endpoint.
19
32
  attr_accessor :application_id
20
33
  # @return [String] the REST API Key for this Parse endpoint.
@@ -67,7 +80,12 @@ module Parse
67
80
  # 3. A session-token-authenticated request (the existing check
68
81
  # below; session token wins over master key).
69
82
  header_disable = env[:request_headers][DISABLE_MASTER_KEY].present?
70
- fiber_disable = Parse.master_key_disabled?
83
+ # An SDK metadata read keeps the master key inside the block. Always
84
+ # remove the marker so it never reaches the logger or the server.
85
+ metadata_value = env[:request_headers].delete(METADATA_MASTER)
86
+ metadata_master = metadata_value.is_a?(String) &&
87
+ ActiveSupport::SecurityUtils.secure_compare(metadata_value, METADATA_MASTER_TOKEN)
88
+ fiber_disable = Parse.master_key_disabled? && !metadata_master
71
89
  unless @master_key.blank? || header_disable || fiber_disable
72
90
  headers[MASTER_KEY] = @master_key
73
91
  end
@@ -70,12 +70,19 @@ module Parse
70
70
  def initialize(reqs = nil, transaction: false)
71
71
  @requests = []
72
72
  @responses = []
73
+ @submitted = false
73
74
  @transaction = transaction
74
75
  reqs = [reqs] unless reqs.is_a?(Enumerable)
75
76
  reqs.each { |r| add(r) } if reqs.is_a?(Enumerable)
76
77
  end
77
78
 
78
79
  # Add an additional request to this batch.
80
+ #
81
+ # A request tagged to a Parse object (see {Parse::Request#tag}) is skipped
82
+ # when the batch already holds an identical request for that same object,
83
+ # so adding an object twice does not send its changes twice. Untagged
84
+ # requests are always kept: two identical raw requests (for example two
85
+ # Increment operations) are two writes and are both sent.
79
86
  # @overload add(req)
80
87
  # @param req [Parse::Request] the request to append.
81
88
  # @return [Array<Parse::Request>] the set of requests.
@@ -83,16 +90,19 @@ module Parse
83
90
  # @param req [Parse::BatchOperation] add all the requests from this batch operation.
84
91
  # @return [Array<Parse::Request>] the set of requests.
85
92
  def add(req)
86
- if req.respond_to?(:change_requests)
87
- requests = req.change_requests.select { |r| r.is_a?(Parse::Request) }
88
- @requests += requests
89
- elsif req.is_a?(Array)
90
- requests = req.select { |r| r.is_a?(Parse::Request) }
91
- @requests += requests
92
- elsif req.is_a?(BatchOperation)
93
- @requests += req.requests if req.is_a?(BatchOperation)
94
- else
95
- @requests.push(req) if req.is_a?(Parse::Request)
93
+ incoming = if req.is_a?(BatchOperation)
94
+ req.requests
95
+ elsif req.respond_to?(:change_requests)
96
+ req.change_requests
97
+ elsif req.is_a?(Array)
98
+ req
99
+ else
100
+ [req]
101
+ end
102
+ incoming.each do |r|
103
+ next unless r.is_a?(Parse::Request)
104
+ next if duplicate_object_request?(r)
105
+ @requests.push(r)
96
106
  end
97
107
  @requests
98
108
  end
@@ -127,13 +137,15 @@ module Parse
127
137
  @requests.clear
128
138
  end
129
139
 
130
- # @return [Boolean] true if the request was successful.
140
+ # @return [Boolean] true if every response in the batch succeeded. A
141
+ # batch that was submitted with no requests is successful; one that has
142
+ # not been submitted is not.
131
143
  def success?
132
- return false if @responses.empty?
133
- @responses.compact.all?(&:success?)
144
+ return @submitted == true if @responses.empty?
145
+ @responses.all? { |r| r.respond_to?(:success?) && r.success? }
134
146
  end
135
147
 
136
- # @return [Boolean] true if the request had an error.
148
+ # @return [Boolean] true if at least one response in the batch failed.
137
149
  def error?
138
150
  return false if @responses.empty?
139
151
  !success?
@@ -143,57 +155,202 @@ module Parse
143
155
  # Parse limits requests in each batch to 50 and it is possible that a {BatchOperation}
144
156
  # instance contains more than 50 requests. This method will slice up the array of
145
157
  # request and send them based on the `segment` amount until they have all been submitted.
158
+ #
159
+ # The returned array always has one response per request, in request
160
+ # order. When a chunk fails as a whole (an HTTP error, or a response that
161
+ # cannot be matched to its requests), every request in that chunk gets a
162
+ # failed response, so a failure never shifts results onto other requests.
163
+ #
164
+ # A transactional batch (`transaction: true`) is never split. All of its
165
+ # requests go to Parse Server in one `POST /batch` with `transaction: true`,
166
+ # which commits or rolls back as a unit, regardless of `segment`. Parse
167
+ # Server has no fixed sub-request limit; a server configured with
168
+ # `requestComplexity.batchRequestLimit` rejects an oversized transaction
169
+ # for non-master callers, and nothing is written.
170
+ #
171
+ # When a chunk raises (for example a 5xx or a dropped connection), the
172
+ # other chunks are still processed and the block still sees every
173
+ # request, with failed responses for the chunk that raised. The first
174
+ # exception is then re-raised.
146
175
  # @param segment [Integer] the number of requests to send in each batch. Default 50.
147
176
  # @param parallelism [Integer] the number of segments dispatched in
148
177
  # parallel. Defaults to `Parse::BatchOperation.parallelism` (2).
178
+ # @yieldparam request [Parse::Request] a submitted request.
179
+ # @yieldparam response [Parse::Response] the response for that request.
149
180
  # @return [Array<Parse::Response>] the corresponding set of responses for
150
181
  # each request in the batch.
151
182
  def submit(segment = 50, parallelism: self.class.parallelism, &block)
152
183
  @responses = []
153
- @requests.uniq!(&:signature)
154
- parallelism = 1 if parallelism.nil? || parallelism < 1
155
- @responses = @requests.each_slice(segment).to_a.threaded_map(parallelism) do |slice|
156
- client.batch_request(BatchOperation.new(slice))
184
+ @submitted = true
185
+ failure = nil
186
+ return @responses if @requests.empty?
187
+
188
+ if @transaction
189
+ # One request, one transaction. Exceptions propagate unchanged so the
190
+ # caller can roll back its local state.
191
+ @responses = align_responses(@requests, client.batch_request(self))
192
+ else
193
+ segment = 50 if segment.nil? || segment < 1
194
+ parallelism = 1 if parallelism.nil? || parallelism < 1
195
+ slices = @requests.each_slice(segment).to_a
196
+ outcomes = slices.threaded_map(parallelism) do |slice|
197
+ begin
198
+ [align_responses(slice, client.batch_request(BatchOperation.new(slice))), nil]
199
+ rescue StandardError => e
200
+ [Array.new(slice.size) { exception_response(e) }, e]
201
+ end
202
+ end
203
+ @responses = outcomes.flat_map(&:first)
204
+ failure = outcomes.map(&:last).compact.first
157
205
  end
158
- @responses.flatten!
206
+
159
207
  @requests.zip(@responses).each(&block) if block_given?
208
+ raise failure if failure
160
209
  @responses
161
210
  end
162
211
 
163
212
  alias_method :save, :submit
213
+
214
+ private
215
+
216
+ # Whether `req` repeats a request already in the batch for the same
217
+ # tagged object.
218
+ def duplicate_object_request?(req)
219
+ tag = req.tag
220
+ return false if tag.nil? || tag == 0
221
+ sig = req.signature
222
+ @requests.any? { |r| r.tag == tag && r.signature == sig }
223
+ end
224
+
225
+ # Pair a chunk's requests with the result of its batch call, one
226
+ # response per request.
227
+ # @param slice [Array<Parse::Request>] the requests that were sent.
228
+ # @param result [Array<Parse::Response>, Parse::Response] the result of
229
+ # {Parse::API::Batch#batch_request}.
230
+ # @return [Array<Parse::Response>]
231
+ def align_responses(slice, result)
232
+ if result.is_a?(Array)
233
+ slice.each_with_index.map do |_req, i|
234
+ entry = result[i]
235
+ next entry if entry.is_a?(Parse::Response)
236
+ Parse::Response.error_response(
237
+ Parse::Response::ERROR_INTERNAL,
238
+ "Batch response had #{result.size} results for #{slice.size} requests",
239
+ )
240
+ end
241
+ elsif result.is_a?(Parse::Response) && result.error?
242
+ slice.map do
243
+ Parse::Response.error_response(result.code, result.error, http_status: result.http_status)
244
+ end
245
+ else
246
+ slice.map do
247
+ Parse::Response.error_response(Parse::Response::ERROR_INTERNAL, "Malformed batch response")
248
+ end
249
+ end
250
+ end
251
+
252
+ # A failed response standing in for a request whose chunk raised.
253
+ def exception_response(error)
254
+ code = Parse::Response::ERROR_INTERNAL
255
+ inner = error.respond_to?(:response) ? error.response : nil
256
+ code = inner.code if inner.respond_to?(:code) && inner.code.is_a?(Integer)
257
+ Parse::Response.error_response(code, "#{error.class}: #{error.message}")
258
+ end
164
259
  end
165
260
  end
166
261
 
167
262
  class Array
168
263
 
169
264
  # Submit a batch request for deleting a set of Parse::Objects.
265
+ #
266
+ # Each object whose delete succeeds has its local state updated the same
267
+ # way {Parse::Object#destroy} updates it. Objects whose delete fails are
268
+ # left untouched; inspect the returned batch's responses to find them.
269
+ # Destroy callbacks are not run. Every {Parse::Session} and {Parse::User}
270
+ # whose delete succeeded, or reported "object not found" (the row is
271
+ # already gone, for example revoked elsewhere), is dropped from its
272
+ # client's identity plane, as their single-object destroy does.
170
273
  # @example
171
274
  # # assume Post and Author are Parse models
172
275
  # author = Author.first
173
276
  # posts = Post.all author: author
174
277
  # posts.destroy # batch destroy request
175
278
  # @return [Parse::BatchOperation] the batch operation performed.
279
+ # @raise ArgumentError if the array is not empty and holds no Parse objects.
176
280
  # @see Parse::BatchOperation
177
281
  def destroy
282
+ targets = select { |o| o.respond_to?(:destroy_request) }
283
+ if targets.empty? && !empty?
284
+ raise ArgumentError, "Array#destroy requires Parse::Object elements; " \
285
+ "this array holds none (#{first.class})"
286
+ end
287
+ # A session deleted from a pointer or a partial fetch carries no token or
288
+ # owner to drop from the identity cache; look them up first, in one
289
+ # query per client.
290
+ Parse::Session.send(:_preload_identity_for_destroy!, targets) if defined?(Parse::Session)
291
+ _destroy_batch(targets)
292
+ ensure
293
+ # A looked-up session token is a live credential; never leave it on an
294
+ # object whose delete was skipped or whose batch raised.
295
+ targets&.each { |o| o.send(:_clear_identity_for_destroy!) if _batch_identity_hook?(o, :_clear_identity_for_destroy!) }
296
+ end
297
+
298
+ # @!visibility private
299
+ def _destroy_batch(targets)
178
300
  batch = Parse::BatchOperation.new
179
- each do |o|
180
- next unless o.respond_to?(:destroy_request)
301
+ objects = {}
302
+ targets.each do |o|
303
+ next if objects.key?(o.object_id)
181
304
  r = o.destroy_request
182
- batch.add(r) unless r.nil?
305
+ next if r.nil?
306
+ objects[o.object_id] = o
307
+ batch.add(r)
308
+ end
309
+ batch.submit do |request, response|
310
+ o = objects[request.tag]
311
+ next unless o
312
+ # Sessions and users drop their identity-plane entries when the delete
313
+ # applied or the row is already gone ("object not found"), so a revoked
314
+ # token stops resolving now. They decide from the response; a denied
315
+ # delete leaves the cache alone.
316
+ o.send(:_after_batch_destroy, response) if _batch_identity_hook?(o, :_after_batch_destroy)
317
+ next unless response.respond_to?(:success?) && response.success?
318
+ # Mirror Parse::Object#destroy: keep the id and mark the object
319
+ # destroyed so it reports `destroyed?` and a later save refuses.
320
+ o.instance_variable_set(:@_destroyed, true)
321
+ o.changes_applied! if o.respond_to?(:changes_applied!)
183
322
  end
184
- batch.submit
185
323
  batch
186
324
  end
325
+ private :_destroy_batch
326
+
327
+ # Whether `o`'s class defines the internal (non-public) identity hook
328
+ # `name`. Checked on the class rather than with `respond_to?(name, true)`
329
+ # so a duck-typed element overriding `respond_to?` cannot break the batch.
330
+ # @!visibility private
331
+ def _batch_identity_hook?(o, name)
332
+ klass = o.class
333
+ klass.private_method_defined?(name) || klass.method_defined?(name)
334
+ end
335
+ private :_batch_identity_hook?
187
336
 
188
337
  # Do not alias method as :delete is already part of array.
189
338
  # alias_method :delete, :destroy
190
339
 
191
- # Submit a batch request for deleting a set of Parse::Objects.
340
+ # Submit a batch request for saving a set of Parse::Objects.
192
341
  # Batch requests are supported implicitly and intelligently through an
193
342
  # extension of array. When an array of Parse::Object subclasses is saved,
194
343
  # Parse-Stack will batch all possible save operations for the objects in the
195
344
  # array that have changed. It will also batch save 50 at a time until all items
196
345
  # in the array are saved. Note: Parse does not allow batch saving Parse::User objects.
346
+ #
347
+ # Each object is updated only from its own responses. An object whose
348
+ # requests all succeed gets its id and timestamps and has its changes
349
+ # cleared. When an object has several requests (an attribute update plus
350
+ # relation updates) and only some succeed, the fields written by the
351
+ # successful requests are cleared and the rest stay dirty, so a later save
352
+ # resends only what failed. An object listed more than once is saved once.
353
+ # Elements that are not Parse objects are skipped.
197
354
  # @note The objects of the array to be saved do not all have to be of the same collection.
198
355
  # @param merge [Boolean] whether to merge the updated changes to the series of
199
356
  # objects back to the original ones submitted. If you don't need the original objects
@@ -206,12 +363,18 @@ class Array
206
363
  # posts.each { |post| post.author = author }
207
364
  # posts.save # batch save
208
365
  # @return [Parse::BatchOperation] the batch operation performed.
366
+ # @raise ArgumentError if the array is not empty and holds no Parse objects.
209
367
  # @see Parse::BatchOperation
210
368
  def save(merge: true, force: false)
369
+ targets = select { |o| o.is_a?(Parse::Object) }
370
+ if targets.empty? && !empty?
371
+ raise ArgumentError, "Array#save requires Parse::Object elements; " \
372
+ "this array holds none (#{first.class})"
373
+ end
211
374
  batch = Parse::BatchOperation.new
212
375
  objects = {}
213
- each do |o|
214
- next unless o.is_a?(Parse::Object)
376
+ targets.each do |o|
377
+ next if objects.key?(o.object_id)
215
378
  objects[o.object_id] = o
216
379
  batch.add o.change_requests(force)
217
380
  end
@@ -219,16 +382,82 @@ class Array
219
382
  batch.submit
220
383
  return batch
221
384
  end
222
- #rebind updates
223
- batch.submit do |request, response|
224
- next unless request.tag.present? && response.present? && response.success?
225
- o = objects[request.tag]
226
- next unless o.is_a?(Parse::Object)
227
- result = response.result
228
- o.id = result["objectId"] if o.id.blank?
229
- o.set_attributes!(result)
230
- o.clear_changes!
385
+ outcomes = Hash.new { |h, k| h[k] = [] }
386
+ begin
387
+ batch.submit do |request, response|
388
+ outcomes[request.tag] << [request, response] if objects.key?(request.tag)
389
+ end
390
+ ensure
391
+ # Apply what landed even when a chunk raised, so a successful create is
392
+ # never left looking new (which would create it again on the next save).
393
+ outcomes.each do |tag, pairs|
394
+ Parse::BatchOperation.apply_save_outcome(objects[tag], pairs)
395
+ end
231
396
  end
232
397
  batch
233
398
  end #save!
234
399
  end
400
+
401
+ module Parse
402
+ class BatchOperation
403
+ # @!visibility private
404
+ # Apply the batch responses for one object's requests to that object.
405
+ # @param obj [Parse::Object] the object that was saved.
406
+ # @param pairs [Array<Array(Parse::Request, Parse::Response)>] its
407
+ # requests and their responses.
408
+ def self.apply_save_outcome(obj, pairs)
409
+ return unless obj.is_a?(Parse::Object)
410
+ ok, failed = pairs.partition { |_req, res| res.respond_to?(:success?) && res.success? }
411
+
412
+ ok.each do |_req, res|
413
+ result = res.result
414
+ next unless result.is_a?(Hash)
415
+ if obj.id.blank? && result[Parse::Model::OBJECT_ID].present?
416
+ obj.instance_variable_set(:@id, result[Parse::Model::OBJECT_ID])
417
+ end
418
+ created = result["createdAt"]
419
+ updated = result["updatedAt"] || created
420
+ obj.instance_variable_set(:@created_at, Parse::Date.parse(created)) if created
421
+ obj.instance_variable_set(:@updated_at, Parse::Date.parse(updated)) if updated
422
+ # beforeSave triggers can change saved fields; apply what came back.
423
+ obj.set_attributes!(result)
424
+ end
425
+ return if ok.empty?
426
+
427
+ if failed.empty?
428
+ obj.changes_applied!
429
+ # Settle array proxies explicitly as well: their dirty state lives on
430
+ # the proxy, and a proxy left dirty resends the whole array on the
431
+ # next save.
432
+ settle_collections(obj, obj.class.fields(:array).keys)
433
+ else
434
+ clear_written_fields(obj, ok.map(&:first), failed.map(&:first))
435
+ end
436
+ end
437
+
438
+ # @!visibility private
439
+ # Clear dirty tracking only for the fields written by successful
440
+ # requests that no failed request also touched.
441
+ def self.clear_written_fields(obj, ok_requests, failed_requests)
442
+ remote = ->(reqs) { reqs.flat_map { |r| r.body.is_a?(Hash) ? r.body.keys.map(&:to_s) : [] } }
443
+ written = remote.call(ok_requests) - remote.call(failed_requests)
444
+ return if written.empty?
445
+ field_map = obj.class.respond_to?(:field_map) ? obj.class.field_map : {}
446
+ local = written.map do |key|
447
+ (field_map.find { |_local, rem| rem.to_s == key }&.first || key).to_s
448
+ end
449
+ settle_collections(obj, local)
450
+ obj.clear_attribute_changes(local) if obj.respond_to?(:clear_attribute_changes)
451
+ end
452
+
453
+ # @!visibility private
454
+ # Mark the collection proxies behind the named fields as saved.
455
+ def self.settle_collections(obj, names)
456
+ names.each do |name|
457
+ next unless obj.respond_to?(name)
458
+ value = obj.send(name)
459
+ value.changes_applied! if value.is_a?(Parse::CollectionProxy) && value.respond_to?(:changes_applied!)
460
+ end
461
+ end
462
+ end
463
+ end
@@ -346,15 +346,36 @@ module Parse
346
346
  puts "[[Response]] --------------------------------------\n"
347
347
  end
348
348
 
349
+ parsed = true
349
350
  begin
350
351
  r = Parse::Response.new(response_env.body)
351
352
  rescue => e
353
+ parsed = false
352
354
  r = Parse::Response.new
353
355
  r.code = response_env.status
354
356
  r.error = "Invalid response for #{env[:method]} #{env[:url]}: #{e}"
355
357
  end
358
+ status = response_env[:status].to_i
356
359
  r.http_status = response_env[:status]
357
360
  r.headers = response_env[:response_headers]
361
+ if parsed && status.between?(200, 299)
362
+ # Parse Server never reports an error with a 2xx status, so a
363
+ # `code` or `error` key in a 2xx body is object data (for example
364
+ # a column set by a beforeSave trigger), not an error envelope.
365
+ r.code = nil
366
+ r.error = nil
367
+ elsif status > 0 && !status.between?(200, 299) && r.error.blank?
368
+ # A non-2xx response is a failure even without a Parse `error`
369
+ # field. It may come from something in front of Parse Server (an
370
+ # API gateway or load balancer returning 502/504 with
371
+ # `{"message": ...}`), or from Parse Server itself (a failed
372
+ # transaction returns `{"code": 1, "message": ...}`). Record the
373
+ # HTTP status as the code when there is none, so the request is
374
+ # never mistaken for a success.
375
+ detail = r.result.is_a?(Hash) ? r.result["message"] : nil
376
+ r.code ||= status
377
+ r.error = detail.present? ? detail.to_s : "HTTP #{status} response without a Parse error body"
378
+ end
358
379
  r.code ||= response_env[:status] if r.error.present?
359
380
  response_env[:body] = r
360
381
  end