parse-stack-next 5.6.0 → 5.7.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
@@ -324,10 +324,10 @@ module Parse
324
324
  # Determine if we should include defaults
325
325
  # Auto-enable if any CLP settings exist and no explicit choice made
326
326
  should_include_defaults = if include_defaults.nil?
327
- present? && @default_permission
328
- else
329
- include_defaults
330
- end
327
+ present? && @default_permission
328
+ else
329
+ include_defaults
330
+ end
331
331
 
332
332
  # Determine the default permission to use
333
333
  # Use explicit default_permission if set, otherwise fall back to public
@@ -94,6 +94,28 @@ module Parse
94
94
  module Core
95
95
  # Defines some of the save, update and destroy operations for Parse objects.
96
96
  module Actions
97
+ # Fiber-local context used to capture an object's state before its first
98
+ # mutation inside a transaction block. `batch.add` is intentionally too
99
+ # late for this: the public API documents mutating an object and adding it
100
+ # afterwards.
101
+ TRANSACTION_CONTEXT_KEY = :__parse_transaction_context__
102
+
103
+ # Distinguishes a property whose ivar did not exist from one explicitly
104
+ # set to nil. Rollback removes the former instead of defining it as nil.
105
+ UNDEFINED_PROPERTY = Object.new.freeze
106
+
107
+ # Non-property state that can change while building/submitting a batch
108
+ # and must be restored along with the `@<field>` property ivars.
109
+ ROLLBACK_STATE_IVARS = %i[
110
+ @changed_attributes
111
+ @id
112
+ @mutations_from_database
113
+ @mutations_before_last_save
114
+ @_acl_snapshot_before_change
115
+ @_acl_pristine
116
+ @_authorization_acl_state
117
+ ].freeze
118
+
97
119
  # @!visibility private
98
120
  def self.included(base)
99
121
  base.extend(ClassMethods)
@@ -109,6 +131,263 @@ module Parse
109
131
  end
110
132
  end
111
133
 
134
+ # Capture the property values a transaction rollback must restore.
135
+ #
136
+ # Property values live in `@<field>` instance variables (see
137
+ # {Parse::Properties::ClassMethods#property}), so those are what gets
138
+ # snapshotted. {Parse::Object#attributes} is deliberately NOT captured:
139
+ # it returns a SCHEMA map (field name to type symbol), not values, so
140
+ # restoring it never rolled anything back.
141
+ #
142
+ # Restoring it was also actively harmful. `@attributes` is the ivar
143
+ # `ActiveModel::Dirty` keys its behavior on: `mutations_from_database`
144
+ # builds an `AttributeMutationTracker` over `@attributes` the moment
145
+ # that ivar is defined (and a `ForcedMutationTracker` otherwise), while
146
+ # `forget_attribute_assignments` calls `map(&:forgetting_assignment)`
147
+ # on it. Handing either one a schema Hash raised `NoMethodError` on a
148
+ # type symbol and on the `[key, value]` pair `Hash#map` yields. Both
149
+ # sites rescue and warn, so every rollback silently downgraded the
150
+ # object to broken change tracking instead of failing.
151
+ #
152
+ # @param obj [Parse::Object] the object being tracked.
153
+ # @return [Hash] field name to a snapshot of its value. Mutable values
154
+ # are duplicated so in-place mutation between snapshot and rollback
155
+ # cannot corrupt the saved copy.
156
+ def self.snapshot_property_values(obj)
157
+ fields = obj.class.respond_to?(:fields) ? obj.class.fields.keys : []
158
+ relations = obj.class.respond_to?(:relations) ? obj.class.relations.keys : []
159
+ property_keys = (fields + relations).uniq
160
+ seen = {}
161
+ property_keys.each_with_object({}) do |key, snapshot|
162
+ ivar = :"@#{key}"
163
+ snapshot[ivar] = if obj.instance_variable_defined?(ivar)
164
+ dup_for_snapshot(obj.instance_variable_get(ivar), seen)
165
+ else
166
+ UNDEFINED_PROPERTY
167
+ end
168
+ end
169
+ end
170
+
171
+ # Copy a property value deeply enough that later in-place mutation of
172
+ # the live value cannot reach the snapshot.
173
+ #
174
+ # A plain `dup` is not sufficient for collection-backed properties:
175
+ # `:array` and association properties hold a {Parse::CollectionProxy},
176
+ # and duplicating the proxy still shares the underlying `@collection`
177
+ # array, so `widget.tags << "b"` would mutate the snapshot too. The
178
+ # proxy's mutable backing arrays and nested values are duplicated as
179
+ # well. Parse objects nested inside values remain references: a pointer
180
+ # property should restore the same object, not manufacture a clone.
181
+ #
182
+ # @param value [Object] the live property value.
183
+ # @return [Object] a copy safe to hold across the transaction.
184
+ def self.dup_for_snapshot(value, seen = {})
185
+ return value if value.nil? || value == true || value == false || value.is_a?(Symbol) || value.is_a?(Numeric)
186
+ return value if defined?(Parse::Pointer) && value.is_a?(Parse::Pointer)
187
+
188
+ object_id = value.object_id
189
+ return seen[object_id] if seen.key?(object_id)
190
+
191
+ case value
192
+ when Array
193
+ copy = value.dup
194
+ copy.clear
195
+ seen[object_id] = copy
196
+ value.each { |item| copy << dup_for_snapshot(item, seen) }
197
+ copy
198
+ when Hash
199
+ copy = value.dup
200
+ copy.clear
201
+ seen[object_id] = copy
202
+ value.each do |key, item|
203
+ copy[dup_for_snapshot(key, seen)] = dup_for_snapshot(item, seen)
204
+ end
205
+ copy
206
+ when Parse::CollectionProxy
207
+ copy = value.dup
208
+ seen[object_id] = copy
209
+ %i[@collection @additions @removals @changed_attributes].each do |ivar|
210
+ next unless value.instance_variable_defined?(ivar)
211
+ copy.instance_variable_set(ivar, dup_for_snapshot(value.instance_variable_get(ivar), seen))
212
+ end
213
+ %i[@mutations_from_database @mutations_before_last_save].each do |ivar|
214
+ next unless value.instance_variable_defined?(ivar)
215
+ tracker = value.instance_variable_get(ivar)
216
+ copy.instance_variable_set(ivar, dup_mutation_tracker(tracker, copy, seen))
217
+ end
218
+ copy
219
+ when Parse::ACL
220
+ copy = Parse::ACL.new(dup_for_snapshot(value.as_json, seen), owner: value.delegate)
221
+ seen[object_id] = copy
222
+ copy
223
+ else
224
+ copy = begin
225
+ value.dup
226
+ rescue TypeError
227
+ # Immutable values are safe to share with the snapshot.
228
+ return value
229
+ end
230
+ seen[object_id] = copy
231
+ copy
232
+ end
233
+ end
234
+
235
+ # Duplicate ActiveModel's mutation tracker without sharing its mutable
236
+ # forced/finalized change hashes. Forced trackers retain their owning
237
+ # Parse object (or copied collection proxy) so dirty reads keep working.
238
+ #
239
+ # @param tracker [Object, nil] ActiveModel mutation tracker.
240
+ # @param owner [Object] object whose attributes the copy should read.
241
+ # @param seen [Hash] identity map used by {dup_for_snapshot}.
242
+ # @return [Object, nil] isolated tracker copy.
243
+ def self.dup_mutation_tracker(tracker, owner, seen = {})
244
+ return nil if tracker.nil?
245
+ return tracker if defined?(ActiveModel::NullMutationTracker) && tracker.is_a?(ActiveModel::NullMutationTracker)
246
+ return seen[tracker.object_id] if seen.key?(tracker.object_id)
247
+
248
+ copy = tracker.dup
249
+ seen[tracker.object_id] = copy
250
+ if defined?(ActiveModel::ForcedMutationTracker) && tracker.is_a?(ActiveModel::ForcedMutationTracker)
251
+ copy.instance_variable_set(:@attributes, owner)
252
+ end
253
+ %i[@forced_changes @finalized_changes].each do |ivar|
254
+ next unless tracker.instance_variable_defined?(ivar)
255
+ copy.instance_variable_set(ivar, dup_for_snapshot(tracker.instance_variable_get(ivar), seen))
256
+ end
257
+ copy
258
+ rescue TypeError
259
+ tracker
260
+ end
261
+
262
+ # Capture all local state needed to restore an object after a failed
263
+ # transaction. This is separate from `#attributes`, which is a schema.
264
+ #
265
+ # @param obj [Parse::Object] object being transacted.
266
+ # @return [Hash] rollback state.
267
+ def self.snapshot_object_state(obj)
268
+ seen = {}
269
+ instance_variables = ROLLBACK_STATE_IVARS.each_with_object({}) do |ivar, snapshot|
270
+ snapshot[ivar] = if obj.instance_variable_defined?(ivar)
271
+ value = obj.instance_variable_get(ivar)
272
+ if %i[@mutations_from_database @mutations_before_last_save].include?(ivar)
273
+ dup_mutation_tracker(value, obj, seen)
274
+ else
275
+ dup_for_snapshot(value, seen)
276
+ end
277
+ else
278
+ UNDEFINED_PROPERTY
279
+ end
280
+ end
281
+
282
+ {
283
+ object: obj,
284
+ property_values: snapshot_property_values(obj),
285
+ instance_variables: instance_variables,
286
+ }
287
+ end
288
+
289
+ # Record that an object was initialized inside the active transaction.
290
+ # Its first rollback snapshot is intentionally taken when it is added to
291
+ # the batch, after initialization, so a failed create stays usable as an
292
+ # initialized unsaved object.
293
+ #
294
+ # @param obj [Parse::Object] newly initialized object.
295
+ # @return [void]
296
+ def self.mark_transaction_object_created(obj)
297
+ context = Fiber[TRANSACTION_CONTEXT_KEY]
298
+ return unless context
299
+ context[:created_objects][obj.object_id] = true
300
+ context[:snapshots].delete(obj.object_id)
301
+ end
302
+
303
+ # Capture an object's state once, before its first transaction mutation.
304
+ # Objects created inside the transaction defer capture until `batch.add`.
305
+ #
306
+ # @param obj [Parse::Object] object whose state should be captured.
307
+ # @param context [Hash, nil] transaction context; defaults to the current fiber.
308
+ # @param include_created [Boolean] capture a newly created object at add time.
309
+ # @return [Hash, nil] the object's rollback state.
310
+ def self.capture_transaction_state(obj, context = Fiber[TRANSACTION_CONTEXT_KEY], include_created: false)
311
+ return unless context && obj
312
+
313
+ object_id = obj.object_id
314
+ return if context[:created_objects].key?(object_id) && !include_created
315
+ context[:snapshots][object_id] ||= snapshot_object_state(obj)
316
+ end
317
+
318
+ # Restore the values captured by {snapshot_property_values}.
319
+ #
320
+ # Writes ivars directly rather than going through the property setters,
321
+ # since the setters mark the object dirty and the caller restores the
322
+ # dirty state itself immediately afterwards.
323
+ #
324
+ # @param obj [Parse::Object] the object being rolled back.
325
+ # @param snapshot [Hash] the return value of {snapshot_property_values}.
326
+ # @return [void]
327
+ def self.restore_property_values(obj, snapshot)
328
+ return unless snapshot.is_a?(Hash)
329
+ snapshot.each do |ivar, value|
330
+ if value.equal?(UNDEFINED_PROPERTY)
331
+ obj.remove_instance_variable(ivar) if obj.instance_variable_defined?(ivar)
332
+ else
333
+ obj.instance_variable_set(ivar, value)
334
+ end
335
+ end
336
+ end
337
+
338
+ # Restore one instance variable while preserving whether it originally
339
+ # existed. Used for dirty/ACL bookkeeping outside the property schema.
340
+ #
341
+ # @param obj [Object] target object.
342
+ # @param ivar [Symbol] instance variable name.
343
+ # @param value [Object] snapshotted value or {UNDEFINED_PROPERTY}.
344
+ # @return [void]
345
+ def self.restore_instance_variable(obj, ivar, value)
346
+ if value.equal?(UNDEFINED_PROPERTY)
347
+ obj.remove_instance_variable(ivar) if obj.instance_variable_defined?(ivar)
348
+ else
349
+ obj.instance_variable_set(ivar, value)
350
+ end
351
+ end
352
+
353
+ # Roll a single tracked object back to the state captured when it was
354
+ # added to the transaction.
355
+ #
356
+ # @param state [Hash] one entry of the transaction's `original_states`.
357
+ # @return [void]
358
+ def self.rollback_object_state(state)
359
+ obj = state[:object]
360
+ return if obj.nil?
361
+ restore_property_values(obj, state[:property_values])
362
+ if state[:instance_variables]
363
+ state[:instance_variables].each do |ivar, value|
364
+ restore_instance_variable(obj, ivar, value)
365
+ end
366
+ else
367
+ # Compatibility with rollback states produced before the complete
368
+ # snapshot format was introduced.
369
+ obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
370
+ obj.instance_variable_set(:@id, state[:id])
371
+ obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
372
+ obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
373
+ end
374
+ end
375
+
376
+ # Hook used by generated property accessors and collection proxies.
377
+ # @api private
378
+ def _capture_transaction_state!
379
+ Parse::Core::Actions.capture_transaction_state(self)
380
+ end
381
+
382
+ # ActiveModel calls this immediately after `<field>_will_change!`; the
383
+ # hook also covers callers that explicitly mark a mutable value dirty.
384
+ def _read_attribute(attr_name)
385
+ _capture_transaction_state!
386
+ super
387
+ end
388
+
389
+ private :_capture_transaction_state!, :_read_attribute
390
+
112
391
  # Class methods applied to Parse::Object subclasses.
113
392
  module ClassMethods
114
393
 
@@ -144,135 +423,109 @@ module Parse
144
423
  def transaction(retries: 5, &block)
145
424
  raise ArgumentError, "Block required for transaction" unless block_given?
146
425
 
147
- batch = Parse::BatchOperation.new(nil, transaction: true)
148
-
149
- # Store original state of objects for rollback
426
+ previous_context = Fiber[TRANSACTION_CONTEXT_KEY]
427
+ transaction_context = { snapshots: {}, created_objects: {} }
428
+ Fiber[TRANSACTION_CONTEXT_KEY] = transaction_context
150
429
  original_states = {}
151
430
  tracked_objects = []
152
431
 
153
- # Wrap the batch to capture objects being added
154
- batch_wrapper = Object.new
155
- batch_wrapper.define_singleton_method(:is_a?) do |klass|
156
- klass == Parse::BatchOperation || super(klass)
157
- end
158
- batch_wrapper.define_singleton_method(:kind_of?) do |klass|
159
- klass == Parse::BatchOperation || super(klass)
160
- end
161
- batch_wrapper.define_singleton_method(:instance_of?) do |klass|
162
- klass == Parse::BatchOperation
163
- end
164
- batch_wrapper.define_singleton_method(:add) do |obj|
165
- # Store original state when object is first added to transaction.
166
- # Use obj.object_id (Ruby identity) as the key because Parse::Object#hash
167
- # and #eql? treat all unsaved objects (nil id) as equal, which would cause
168
- # only the first unsaved object to be tracked.
169
- if obj.respond_to?(:attributes) && obj.respond_to?(:id) && !original_states.key?(obj.object_id)
170
- original_states[obj.object_id] = {
171
- object: obj,
172
- attributes: obj.attributes.dup,
173
- changed_attributes: obj.instance_variable_get(:@changed_attributes)&.dup || {},
174
- id: obj.id,
175
- mutations_from_database: obj.instance_variable_get(:@mutations_from_database),
176
- mutations_before_last_save: obj.instance_variable_get(:@mutations_before_last_save),
177
- }
178
- tracked_objects << obj
432
+ begin
433
+ batch = Parse::BatchOperation.new(nil, transaction: true)
434
+
435
+ # Wrap the batch to associate the pre-mutation snapshot with each
436
+ # object that actually participates in the transaction.
437
+ batch_wrapper = Object.new
438
+ batch_wrapper.define_singleton_method(:is_a?) do |klass|
439
+ klass == Parse::BatchOperation || super(klass)
440
+ end
441
+ batch_wrapper.define_singleton_method(:kind_of?) do |klass|
442
+ klass == Parse::BatchOperation || super(klass)
443
+ end
444
+ batch_wrapper.define_singleton_method(:instance_of?) do |klass|
445
+ klass == Parse::BatchOperation
446
+ end
447
+ batch_wrapper.define_singleton_method(:add) do |obj|
448
+ # Ruby identity is required because all unsaved Parse objects
449
+ # compare equal while their ids are nil.
450
+ if obj.respond_to?(:attributes) && obj.respond_to?(:id) && !original_states.key?(obj.object_id)
451
+ original_states[obj.object_id] = Parse::Core::Actions.capture_transaction_state(
452
+ obj,
453
+ transaction_context,
454
+ include_created: true,
455
+ )
456
+ tracked_objects << obj
457
+ end
458
+ batch.add(obj)
179
459
  end
180
- batch.add(obj)
181
- end
182
460
 
183
- # Forward other methods to the real batch
184
- batch_wrapper.define_singleton_method(:method_missing) do |method, *args, &block|
185
- batch.send(method, *args, &block)
186
- end
461
+ # Forward other methods to the real batch.
462
+ batch_wrapper.define_singleton_method(:method_missing) do |method, *args, &method_block|
463
+ batch.send(method, *args, &method_block)
464
+ end
465
+ batch_wrapper.define_singleton_method(:respond_to_missing?) do |method, include_private = false|
466
+ batch.respond_to?(method, include_private)
467
+ end
187
468
 
188
- result = yield(batch_wrapper)
469
+ result = yield(batch_wrapper)
189
470
 
190
- # If block returns objects, add them to batch
191
- if result.respond_to?(:change_requests)
192
- batch_wrapper.add(result)
193
- elsif result.is_a?(Array)
194
- result.each { |obj| batch_wrapper.add(obj) if obj.respond_to?(:change_requests) }
195
- end
471
+ # If block returns objects, add them to batch.
472
+ if result.respond_to?(:change_requests)
473
+ batch_wrapper.add(result)
474
+ elsif result.is_a?(Array)
475
+ result.each { |obj| batch_wrapper.add(obj) if obj.respond_to?(:change_requests) }
476
+ end
196
477
 
197
- # Submit with retry logic for transaction conflicts
198
- attempts = 0
199
- begin
200
- attempts += 1
201
- responses = batch.submit
202
-
203
- # Check for success
204
- if responses.all?(&:success?)
205
- # Update tracked objects with data from successful responses
206
- # Match responses to objects using the request tag (Ruby object_id)
207
- # Build hash lookup once for O(n) instead of O(n²) linear search
208
- objects_by_id = tracked_objects.each_with_object({}) { |o, h| h[o.object_id] = o }
209
- requests = batch.requests
210
- requests.zip(responses).each do |request, response|
211
- next unless request && response && response.success?
212
- result = response.result
213
- next unless result.is_a?(Hash)
214
-
215
- # Find the object matching this request's tag
216
- obj = objects_by_id[request.tag]
217
- next unless obj
218
-
219
- # Update object with response data (objectId, createdAt, updatedAt)
220
- if result["objectId"]
221
- obj.instance_variable_set(:@id, result["objectId"])
222
- end
223
- if result["createdAt"]
224
- obj.instance_variable_set(:@created_at, Parse::Date.parse(result["createdAt"]))
225
- end
226
- if result["updatedAt"]
227
- obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["updatedAt"]))
228
- elsif result["createdAt"]
229
- obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["createdAt"]))
478
+ # Submit with retry logic for transaction conflicts.
479
+ attempts = 0
480
+ begin
481
+ attempts += 1
482
+ responses = batch.submit
483
+
484
+ if responses.all?(&:success?)
485
+ # Match responses to objects using the request tag (Ruby object_id).
486
+ objects_by_id = tracked_objects.each_with_object({}) { |o, h| h[o.object_id] = o }
487
+ batch.requests.zip(responses).each do |request, response|
488
+ next unless request && response && response.success?
489
+ result = response.result
490
+ next unless result.is_a?(Hash)
491
+
492
+ obj = objects_by_id[request.tag]
493
+ next unless obj
494
+
495
+ obj.instance_variable_set(:@id, result["objectId"]) if result["objectId"]
496
+ if result["createdAt"]
497
+ obj.instance_variable_set(:@created_at, Parse::Date.parse(result["createdAt"]))
498
+ end
499
+ if result["updatedAt"]
500
+ obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["updatedAt"]))
501
+ elsif result["createdAt"]
502
+ obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["createdAt"]))
503
+ end
504
+
505
+ # Apply any additional attributes returned by beforeSave hooks.
506
+ obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
507
+ obj.send(:clear_changes!) if obj.respond_to?(:clear_changes!, true)
230
508
  end
231
509
 
232
- # Apply any additional attributes returned by beforeSave hooks
233
- obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
234
-
235
- # Clear change tracking since save was successful
236
- obj.send(:clear_changes!) if obj.respond_to?(:clear_changes!, true)
237
- end
238
-
239
- return responses
240
- else
241
- # Find first error
242
- error_response = responses.find { |r| !r.success? }
243
-
244
- # Rollback local object states
245
- original_states.each_value do |state|
246
- obj = state[:object]
247
- obj.instance_variable_set(:@attributes, state[:attributes])
248
- obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
249
- obj.instance_variable_set(:@id, state[:id])
250
- # Restore change tracking state
251
- obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
252
- obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
510
+ return responses
253
511
  end
254
512
 
513
+ error_response = responses.find { |response| !response.success? }
255
514
  raise Parse::Error, "Transaction failed: #{error_response.error}"
515
+ rescue Parse::Error => e
516
+ if e.message.include?("251") && attempts < retries
517
+ sleep(0.1 * attempts)
518
+ retry
519
+ end
520
+ raise
256
521
  end
257
- rescue Parse::Error => e
258
- # Retry on transaction conflict (error code 251)
259
- if e.message.include?("251") && attempts < retries
260
- sleep(0.1 * attempts) # Exponential backoff
261
- retry
262
- end
263
-
264
- # Rollback local object states on final failure
522
+ rescue StandardError
265
523
  original_states.each_value do |state|
266
- obj = state[:object]
267
- obj.instance_variable_set(:@attributes, state[:attributes])
268
- obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
269
- obj.instance_variable_set(:@id, state[:id])
270
- # Restore change tracking state
271
- obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
272
- obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
524
+ Parse::Core::Actions.rollback_object_state(state)
273
525
  end
274
-
275
- raise e
526
+ raise
527
+ ensure
528
+ Fiber[TRANSACTION_CONTEXT_KEY] = previous_context
276
529
  end
277
530
  end
278
531
 
@@ -276,5 +276,3 @@ module Parse
276
276
  end
277
277
  end
278
278
  end
279
-
280
-