parse-stack-next 5.7.0 → 5.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -133,10 +155,16 @@ module Parse
133
155
  # cannot corrupt the saved copy.
134
156
  def self.snapshot_property_values(obj)
135
157
  fields = obj.class.respond_to?(:fields) ? obj.class.fields.keys : []
136
- fields.each_with_object({}) do |key, snapshot|
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|
137
162
  ivar = :"@#{key}"
138
- next unless obj.instance_variable_defined?(ivar)
139
- snapshot[ivar] = dup_for_snapshot(obj.instance_variable_get(ivar))
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
140
168
  end
141
169
  end
142
170
 
@@ -147,25 +175,144 @@ module Parse
147
175
  # `:array` and association properties hold a {Parse::CollectionProxy},
148
176
  # and duplicating the proxy still shares the underlying `@collection`
149
177
  # array, so `widget.tags << "b"` would mutate the snapshot too. The
150
- # proxy's inner array is duplicated as well.
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.
151
181
  #
152
182
  # @param value [Object] the live property value.
153
183
  # @return [Object] a copy safe to hold across the transaction.
154
- def self.dup_for_snapshot(value)
155
- copy = begin
156
- value.dup
157
- rescue TypeError
158
- # Symbols, Integers, true/false/nil and other immediates are not
159
- # duplicable on older rubies; they are also immutable, so sharing
160
- # the reference is safe.
161
- return value
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))
162
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
163
234
 
164
- if copy.instance_variable_defined?(:@collection)
165
- inner = copy.instance_variable_get(:@collection)
166
- copy.instance_variable_set(:@collection, inner.dup) if inner.is_a?(Array)
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))
167
256
  end
168
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)
169
316
  end
170
317
 
171
318
  # Restore the values captured by {snapshot_property_values}.
@@ -180,6 +327,25 @@ module Parse
180
327
  def self.restore_property_values(obj, snapshot)
181
328
  return unless snapshot.is_a?(Hash)
182
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
183
349
  obj.instance_variable_set(ivar, value)
184
350
  end
185
351
  end
@@ -193,16 +359,35 @@ module Parse
193
359
  obj = state[:object]
194
360
  return if obj.nil?
195
361
  restore_property_values(obj, state[:property_values])
196
- obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
197
- obj.instance_variable_set(:@id, state[:id])
198
- # Restore change tracking state. Leaving `@mutations_from_database`
199
- # nil is fine: `ActiveModel::Dirty` lazily rebuilds it, and with
200
- # `@attributes` no longer defined on the object it correctly rebuilds
201
- # a `ForcedMutationTracker`.
202
- obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
203
- obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
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
204
387
  end
205
388
 
389
+ private :_capture_transaction_state!, :_read_attribute
390
+
206
391
  # Class methods applied to Parse::Object subclasses.
207
392
  module ClassMethods
208
393
 
@@ -238,123 +423,109 @@ module Parse
238
423
  def transaction(retries: 5, &block)
239
424
  raise ArgumentError, "Block required for transaction" unless block_given?
240
425
 
241
- batch = Parse::BatchOperation.new(nil, transaction: true)
242
-
243
- # 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
244
429
  original_states = {}
245
430
  tracked_objects = []
246
431
 
247
- # Wrap the batch to capture objects being added
248
- batch_wrapper = Object.new
249
- batch_wrapper.define_singleton_method(:is_a?) do |klass|
250
- klass == Parse::BatchOperation || super(klass)
251
- end
252
- batch_wrapper.define_singleton_method(:kind_of?) do |klass|
253
- klass == Parse::BatchOperation || super(klass)
254
- end
255
- batch_wrapper.define_singleton_method(:instance_of?) do |klass|
256
- klass == Parse::BatchOperation
257
- end
258
- batch_wrapper.define_singleton_method(:add) do |obj|
259
- # Store original state when object is first added to transaction.
260
- # Use obj.object_id (Ruby identity) as the key because Parse::Object#hash
261
- # and #eql? treat all unsaved objects (nil id) as equal, which would cause
262
- # only the first unsaved object to be tracked.
263
- if obj.respond_to?(:attributes) && obj.respond_to?(:id) && !original_states.key?(obj.object_id)
264
- original_states[obj.object_id] = {
265
- object: obj,
266
- property_values: Parse::Core::Actions.snapshot_property_values(obj),
267
- changed_attributes: obj.instance_variable_get(:@changed_attributes)&.dup || {},
268
- id: obj.id,
269
- mutations_from_database: obj.instance_variable_get(:@mutations_from_database),
270
- mutations_before_last_save: obj.instance_variable_get(:@mutations_before_last_save),
271
- }
272
- 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)
273
459
  end
274
- batch.add(obj)
275
- end
276
460
 
277
- # Forward other methods to the real batch
278
- batch_wrapper.define_singleton_method(:method_missing) do |method, *args, &block|
279
- batch.send(method, *args, &block)
280
- 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
281
468
 
282
- result = yield(batch_wrapper)
469
+ result = yield(batch_wrapper)
283
470
 
284
- # If block returns objects, add them to batch
285
- if result.respond_to?(:change_requests)
286
- batch_wrapper.add(result)
287
- elsif result.is_a?(Array)
288
- result.each { |obj| batch_wrapper.add(obj) if obj.respond_to?(:change_requests) }
289
- 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
290
477
 
291
- # Submit with retry logic for transaction conflicts
292
- attempts = 0
293
- begin
294
- attempts += 1
295
- responses = batch.submit
296
-
297
- # Check for success
298
- if responses.all?(&:success?)
299
- # Update tracked objects with data from successful responses
300
- # Match responses to objects using the request tag (Ruby object_id)
301
- # Build hash lookup once for O(n) instead of O(n²) linear search
302
- objects_by_id = tracked_objects.each_with_object({}) { |o, h| h[o.object_id] = o }
303
- requests = batch.requests
304
- requests.zip(responses).each do |request, response|
305
- next unless request && response && response.success?
306
- result = response.result
307
- next unless result.is_a?(Hash)
308
-
309
- # Find the object matching this request's tag
310
- obj = objects_by_id[request.tag]
311
- next unless obj
312
-
313
- # Update object with response data (objectId, createdAt, updatedAt)
314
- if result["objectId"]
315
- obj.instance_variable_set(:@id, result["objectId"])
316
- end
317
- if result["createdAt"]
318
- obj.instance_variable_set(:@created_at, Parse::Date.parse(result["createdAt"]))
319
- end
320
- if result["updatedAt"]
321
- obj.instance_variable_set(:@updated_at, Parse::Date.parse(result["updatedAt"]))
322
- elsif result["createdAt"]
323
- 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)
324
508
  end
325
509
 
326
- # Apply any additional attributes returned by beforeSave hooks
327
- obj.set_attributes!(result) if obj.respond_to?(:set_attributes!)
328
-
329
- # Clear change tracking since save was successful
330
- obj.send(:clear_changes!) if obj.respond_to?(:clear_changes!, true)
331
- end
332
-
333
- return responses
334
- else
335
- # Find first error
336
- error_response = responses.find { |r| !r.success? }
337
-
338
- # Rollback local object states
339
- original_states.each_value do |state|
340
- Parse::Core::Actions.rollback_object_state(state)
510
+ return responses
341
511
  end
342
512
 
513
+ error_response = responses.find { |response| !response.success? }
343
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
344
521
  end
345
- rescue Parse::Error => e
346
- # Retry on transaction conflict (error code 251)
347
- if e.message.include?("251") && attempts < retries
348
- sleep(0.1 * attempts) # Exponential backoff
349
- retry
350
- end
351
-
352
- # Rollback local object states on final failure
522
+ rescue StandardError
353
523
  original_states.each_value do |state|
354
524
  Parse::Core::Actions.rollback_object_state(state)
355
525
  end
356
-
357
- raise e
526
+ raise
527
+ ensure
528
+ Fiber[TRANSACTION_CONTEXT_KEY] = previous_context
358
529
  end
359
530
  end
360
531
 
@@ -687,7 +687,7 @@ module Parse
687
687
  return if stored_digest == digest && target_present
688
688
 
689
689
  provider = Parse::Embeddings.provider(directive.provider_name)
690
- vectors = call_provider(provider, directive, input)
690
+ vectors = call_provider(provider, directive, input, record)
691
691
  unless vectors.is_a?(Array) && vectors.length == 1 && vectors.first.is_a?(Array)
692
692
  raise Parse::Embeddings::InvalidResponseError,
693
693
  "Parse::Core::EmbedManaged (#{record.class}##{directive.into}): provider " \
@@ -774,16 +774,27 @@ module Parse
774
774
  # provider a {Parse::Embeddings::ImageFetch::FetchedImage}; `:url`
775
775
  # mode forwards the raw URL String (the provider validates and
776
776
  # fetches it itself).
777
- def self.call_provider(provider, directive, input)
777
+ #
778
+ # `input` is the bare canonical URL used for the digest (see
779
+ # {.build_source_input}). It stays stable across saves, so an
780
+ # unsigned re-read of the same file location does not force a
781
+ # re-embed.
782
+ # The actual fetch/forward target prefers the file's presigned
783
+ # URL ({Parse::File#presigned_url}) when one is currently valid,
784
+ # since a private-bucket adapter's bare `file.url` is stripped of
785
+ # its signature and will not resolve for the provider or for the
786
+ # SDK's own `:bytes`-mode download.
787
+ def self.call_provider(provider, directive, input, record)
778
788
  if directive.image?
789
+ fetch_url = presigned_fetch_url(record, directive, input)
779
790
  source = if directive.bytes_mode?
780
791
  Parse::Embeddings::ImageFetch.fetch!(
781
- input,
792
+ fetch_url,
782
793
  allow_insecure: directive.allow_insecure ? true : false,
783
794
  exif_strip: directive.exif_strip != false,
784
795
  )
785
796
  else
786
- input
797
+ fetch_url
787
798
  end
788
799
  provider.embed_image([source],
789
800
  input_type: directive.input_type,
@@ -793,6 +804,32 @@ module Parse
793
804
  end
794
805
  end
795
806
 
807
+ # @!visibility private
808
+ # Resolve the URL to actually fetch/forward for an image
809
+ # directive: the source file's currently-valid presigned URL if
810
+ # it has one, otherwise the bare canonical `fallback` (the same
811
+ # string used for the digest). Never used for text directives, so
812
+ # `directive.sources.first` is always a `:file` property here.
813
+ #
814
+ # Checks validity with a zero safety buffer rather than
815
+ # {Parse::File#presigned_url_valid?}'s default 60-second one. That
816
+ # default exists so a browser has time to render before a
817
+ # presigned URL goes stale; here it would instead spend the last
818
+ # 60 seconds of a perfectly usable presigned URL falling back to
819
+ # `fallback`, which on a private-bucket adapter is not fetchable
820
+ # at all. A fetch that starts immediately after this check has no
821
+ # meaningful use for that margin, and a 403 from a URL that
822
+ # expired mid-request is strictly better than a guaranteed 403
823
+ # from a URL known unfetchable in advance.
824
+ def self.presigned_fetch_url(record, directive, fallback)
825
+ file = record.public_send(directive.sources.first)
826
+ if file.respond_to?(:presigned_url_valid?) && file.presigned_url_valid?(buffer: 0)
827
+ file.presigned_url
828
+ else
829
+ fallback
830
+ end
831
+ end
832
+
796
833
  # @!visibility private
797
834
  # Concatenate source-field string values. `nil` and blank entries
798
835
  # are skipped; remaining values are joined with a double newline.
@@ -512,6 +512,10 @@ module Parse
512
512
  @_fetched_keys ||= []
513
513
  @_fetched_keys << key unless @_fetched_keys.include?(key)
514
514
  end
515
+
516
+ # A transaction must capture the value before the setter marks or
517
+ # replaces it. `batch.add` happens after mutation in the public API.
518
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
515
519
  end
516
520
  end
517
521
  end
@@ -573,6 +573,10 @@ module Parse
573
573
  value = instance_variable_get ivar
574
574
  end
575
575
 
576
+ # Capture after any implicit fetch, but before a default value or
577
+ # mutable collection is materialized by this getter.
578
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
579
+
576
580
  # if value is nil (even after fetching), then lets see if the developer
577
581
  # set a default value for this attribute.
578
582
  if value.nil? && respond_to?("#{key}_default")
@@ -253,7 +253,6 @@ module Parse
253
253
  # same created_at date (down to the microsecond). This prevents getting the same
254
254
  # record in the next query request.
255
255
  exclusion_set = results.select { |r| r.created_at == next_cursor.created_at }.map(&:id)
256
- results = nil
257
256
  cursor = next_cursor
258
257
  end
259
258
  end
@@ -356,7 +355,6 @@ module Parse
356
355
  # Object.latest(:user.eq => user, limit: 5) # => 5 most recent for user
357
356
  # @return [Parse::Object] the most recently created object matching constraints.
358
357
  def latest(constraints = {})
359
- fetch_count = 1
360
358
  if constraints.is_a?(Numeric)
361
359
  fetch_count = constraints.to_i
362
360
  constraints = {}
@@ -385,7 +383,6 @@ module Parse
385
383
  # Object.last_updated(:user.eq => user, limit: 3) # => 3 most recently updated for user
386
384
  # @return [Parse::Object] the most recently updated object matching constraints.
387
385
  def last_updated(constraints = {})
388
- fetch_count = 1
389
386
  if constraints.is_a?(Numeric)
390
387
  fetch_count = constraints.to_i
391
388
  constraints = {}
@@ -600,7 +597,6 @@ module Parse
600
597
  parse_ids.compact!
601
598
  # determines if the result back to the call site is an array or a single result
602
599
  as_array = parse_ids.count > 1
603
- results = []
604
600
 
605
601
  # Default to write-only cache mode - find always gets fresh data
606
602
  # but updates cache for future cached reads. Controlled by feature flag.
@@ -1337,6 +1337,8 @@ module Parse
1337
1337
  # for trusted hydration from server JSON; it bypasses the filter.
1338
1338
  # @return [Parse::Object] a the corresponding Parse::Object or subclass.
1339
1339
  def initialize(opts = {})
1340
+ Parse::Core::Actions.mark_transaction_object_created(self)
1341
+
1340
1342
  # Trusted hydration is signalled by the `@_trusted_init` instance
1341
1343
  # variable rather than by a `trusted:` keyword argument. Using a
1342
1344
  # keyword would break subclasses that override `initialize(*args)`
@@ -1865,7 +1867,6 @@ module Parse
1865
1867
  # we should do a reverse lookup on who is registered for a different class type
1866
1868
  # than their name with parse_class
1867
1869
  klass = Parse::Model.find_class className
1868
- o = nil
1869
1870
  if klass.present?
1870
1871
  # when creating objects from Parse JSON data, don't use dirty tracking since
1871
1872
  # we are considering these objects as "pristine"
@@ -2042,6 +2043,8 @@ module Parse
2042
2043
  # caller intent to override.
2043
2044
  # @api private
2044
2045
  def acl_will_change!
2046
+ _capture_transaction_state!
2047
+
2045
2048
  # Only capture snapshot on the first change (before any modifications)
2046
2049
  unless defined?(@_acl_snapshot_before_change) && @_acl_snapshot_before_change
2047
2050
  # Deep copy the ACL by creating a new one from its JSON representation