parse-stack-next 5.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a554fcec1f40152e03956f033d593c1a4a98fa09ef22e033c270721c701d336
4
- data.tar.gz: 10db25291fc3e2f1e61a36df7499f5b38af2e4e04f73f72f7adfaca8c55dd156
3
+ metadata.gz: 7cb6a2c813f0a1b2d624852680651ed289f7aac282fa24e246b28d0ed9e69383
4
+ data.tar.gz: a3984ae30555af28eadfdc19fa936103bb252bad332b0ddb8ead163277ae4bde
5
5
  SHA512:
6
- metadata.gz: aa3333741642e2faea789f829e23c37f3b72dccba2a4c5ed85b3601d2a945a58767cc65cd55ab9eb385225cf6d61cccbe0e4fb824891c273bc735c3c792916e5
7
- data.tar.gz: 572f5a1bd5683d2e9a7aad5293e279401680e268359e3d9f805875453a92ac4ca570ac9c4da9a7c85542b986b91544fcf6adffe223314af0aef7a3828071f7cf
6
+ metadata.gz: d19428000255676b8a5f10ee0087bf3b6d21216c499f56d895d1ee50a646985a4297b725807b2b19878a8576415c8e1551471d34fa236e9bf90170e6e13abed5
7
+ data.tar.gz: 5db3159d3da214300df9e21760ae871f070f6a79d961fe9e082b5160dd0bd2c07cba5d64656791b961ab2c347e9ebf5151eb607fcf0a3768587a3c382476000e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.7.1
4
+
5
+ #### Cache-invalidation webhooks no longer break every application hook for the same trigger
6
+
7
+ - **FIXED**: `Parse::Cache::Invalidation` raised `NoMethodError: undefined
8
+ method 'guard'` on every `_User`, `_Role`, and `_Session` trigger it
9
+ registered, and the failure was not contained. A webhook handler block is
10
+ bound to the payload before it runs, so the bare `guard` / `bump_subject` /
11
+ `subject_id` calls in the handler bodies resolved against
12
+ `Parse::Webhooks::Payload`, which defines none of them. `guard`'s own
13
+ `rescue` never ran, because it lives inside the body that was never entered,
14
+ so the error escaped into the route dispatcher. The dispatcher folds a
15
+ trigger's handlers with `Array#map`, which abandons the collection on the
16
+ first raise, and these triggers install during `Parse.setup` and therefore
17
+ sit ahead of every application handler for the same trigger. One
18
+ unresolvable method name silently prevented an application's own
19
+ `after_save "_User"` hook from running at all, so any work downstream of it
20
+ stopped without a visible cause. The handlers now call the module on an
21
+ explicit receiver captured in a local, which is independent of whatever
22
+ `self` the dispatcher binds. Applications that set
23
+ `cache_invalidation_hooks: false` to work around this can remove it.
24
+ - **FIXED**: The invalidation tests dispatched handlers with a plain
25
+ `Proc#call`, which leaves `self` bound to the module the block closed over,
26
+ so every one of them passed against handlers that could not run in
27
+ production. They now dispatch through the real handler invocation and fail
28
+ against the broken code.
29
+
30
+ #### Whether one failing `after_*` handler stops the rest is now a setting
31
+
32
+ - **NEW**: `Parse::Webhooks.abort_after_callbacks_on_error` decides whether an
33
+ exception raised by one `after_*` handler prevents the remaining handlers for
34
+ that trigger from running. It defaults to `true`, which is the existing
35
+ behavior, so nothing changes on upgrade. Set it to `false` to isolate
36
+ handlers from each other: each one runs regardless of what an earlier one
37
+ raised, and the failure is reported with a warning and a
38
+ `parse.webhooks.handler_error` notification instead of being swallowed
39
+ silently. This was previously not a decision at all but a side effect of
40
+ folding handlers with `Array#map`, which abandons the collection on the
41
+ first raise. Registration order is not fully under an application's control,
42
+ since the SDK's own cache-invalidation triggers install during `Parse.setup`
43
+ and therefore sit ahead of handlers registered by application files loaded
44
+ later, so a single raising handler could silently prevent an application's
45
+ own hooks from running with nothing logged to say they had been skipped.
46
+ - **BEHAVIOR**: The setting governs only whether later handlers still run.
47
+ Neither mode reverts anything: an `after_*` trigger fires once the write has
48
+ already committed, so there is no version of this setting that can undo a
49
+ save. `before_*` dispatch is untouched, and only the accumulating,
50
+ non-rejectable triggers (`after_save`, `after_delete`, `after_logout`) can
51
+ hold more than one handler in the first place. A rejectable `before_*`
52
+ trigger must deny if any handler denies, so its raise must continue to
53
+ abort.
54
+
55
+ #### Failed transactions restore the object they rolled back
56
+
57
+ - **FIXED**: A failed `Parse::Object.transaction` left the in-memory object
58
+ holding its modified values. The rollback snapshotted `Parse::Object#attributes`
59
+ and restored it, but that method returns a schema map of field name to type
60
+ symbol rather than values, so nothing was ever restored. Property values
61
+ live in `@<field>` instance variables; those are now what the rollback
62
+ captures and restores, including values nested inside arrays and hashes,
63
+ relation operation queues, and properties whose ivar did not exist before
64
+ the transaction. State is captured when the object first enters the
65
+ transaction rather than at `batch.add`, so the documented pattern of
66
+ mutating an object and adding it afterwards rolls back correctly.
67
+ - **FIXED**: A failed transaction also left the object with broken change
68
+ tracking. Restoring the schema map defined `@attributes`, which is the
69
+ instance variable `ActiveModel::Dirty` keys its behavior on: once defined it
70
+ builds an `AttributeMutationTracker` over that hash instead of the
71
+ `ForcedMutationTracker` a `Parse::Object` needs, and `clear_changes!` then
72
+ called `forgetting_assignment` on the `[key, value]` pairs `Hash#map`
73
+ yields. Both call sites rescue and warn, so every rollback quietly
74
+ downgraded the object rather than failing. The rollback no longer defines
75
+ `@attributes`.
76
+ - **FIXED**: The mongo-direct role-graph integration test gated on
77
+ `ANALYTICS_DATABASE_URI`, a production variable name the test stack never
78
+ sets, so both of its traversal assertions skipped on every run while the
79
+ per-file reporter still reported the file as passing. It now reads
80
+ `PARSE_TEST_MONGO_URI` like every other mongo-direct integration file and
81
+ still honors `ANALYTICS_DATABASE_URI` as an override.
82
+
3
83
  ### 5.7.0
4
84
 
5
85
  #### Cache keys move into a reserved, app-scoped keyspace
@@ -205,7 +285,7 @@
205
285
  Server's self-access rules, and role-only checks cannot claim a concrete
206
286
  member's pointer or `_User` self permission. CLP cache entries are isolated
207
287
  by Parse application so identically named classes cannot leak policy across
208
- clients. These helpers are advisorythe eventual Parse Server request is
288
+ clients. These helpers are advisory: the eventual Parse Server request is
209
289
  still authoritative.
210
290
 
211
291
  #### Test infrastructure
@@ -52,26 +52,111 @@ module Parse
52
52
  registered
53
53
  end
54
54
 
55
+ # Run a role-trigger invalidation.
56
+ #
57
+ # Public because the registered webhook block calls it on an explicit
58
+ # receiver. See {handle_identity_trigger} for why that matters.
59
+ #
60
+ # @!visibility private
61
+ # @param cache [Parse::Cache::Redis] the keyspace-configured cache.
62
+ # @return [void]
63
+ def handle_role_trigger(cache)
64
+ guard do
65
+ # A role write does not say which users are affected: membership
66
+ # and hierarchy changes arrive as relation deltas on `users` and
67
+ # `roles`, and the cached value is a flattened transitive closure,
68
+ # so a parent-role change reaches the members of every child.
69
+ # Clearing the whole plane is both correct and cheap under a
70
+ # scoped SCAN. Parse Server does the same, for the same reason.
71
+ cache.roles.clear
72
+ # Stamp the epoch so a *foreign* role entry written before this
73
+ # moment is rejected on read. Parse Server does not clear its own
74
+ # role cache on a `_Role` delete, so without this the next read
75
+ # would take its stale entry back and our clear would shorten
76
+ # revocation by nothing.
77
+ cache.roles.touch_epoch
78
+ end
79
+ end
80
+
81
+ # Run an identity-trigger invalidation.
82
+ #
83
+ # Public, and invoked on an explicit receiver, because a webhook
84
+ # handler block does NOT run with `self` bound to the module that
85
+ # created it. {Parse::Webhooks.invoke_handler} binds the block to the
86
+ # payload (`payload.define_singleton_method(name, &block)`), and the
87
+ # historical `payload.instance_exec(payload, &block)` did the same.
88
+ # A block body calling a bare `guard` / `bump_subject` / `subject_id`
89
+ # therefore resolved against {Parse::Webhooks::Payload}, which defines
90
+ # none of them, and raised `NoMethodError` on every `_User`, `_Role`,
91
+ # and `_Session` trigger.
92
+ #
93
+ # That failure was not contained. `guard`'s `rescue` lives inside
94
+ # `guard`'s own body, which was never entered, so the error escaped
95
+ # into `call_route`'s `registry.map`. `Array#map` abandons the whole
96
+ # collection on the first raise, and these triggers register at
97
+ # `Parse.setup` and therefore sit AHEAD of any application handler for
98
+ # the same trigger. One unresolvable method name silently prevented
99
+ # every application `after_save "_User"` hook from running at all.
100
+ #
101
+ # Capturing the module in a local and calling it explicitly is what
102
+ # makes the handler independent of whatever `self` the dispatcher
103
+ # binds.
104
+ #
105
+ # @!visibility private
106
+ # @param cache [Parse::Cache::Redis] the keyspace-configured cache.
107
+ # @param type [Symbol] the trigger being handled.
108
+ # @param payload [Parse::Webhooks::Payload] the incoming payload.
109
+ # @return [void]
110
+ def handle_identity_trigger(cache, type, payload)
111
+ guard do
112
+ case type
113
+ when :after_logout
114
+ # The only trigger Parse Server permits on `_Session`. The
115
+ # object's own sessionToken is scrubbed from the payload, but
116
+ # the token is captured from the requesting user before
117
+ # scrubbing, and for a logout that user *is* the session being
118
+ # ended. A master-key logout carries no user, so fall back to
119
+ # the generation bump.
120
+ #
121
+ # Pass the RAW token, not a pre-hashed digest.
122
+ # `Parse::Cache::SubCache#invalidate` hashes its `key`
123
+ # argument internally for the `:idn` family (see
124
+ # `SubCache#logical_key`), the same way `#get` / `#set` do.
125
+ # That is what makes a `set(raw_token, ...)` /
126
+ # `get(raw_token)` pair round-trip. Hashing here first and
127
+ # handing SubCache an already-hashed value made it hash the
128
+ # digest a second time, landing on a key nothing had ever
129
+ # written to, so logout silently failed to evict the entry.
130
+ token = payload.respond_to?(:session_token) ? payload.session_token : nil
131
+ if token && !token.to_s.empty?
132
+ cache.identity.invalidate(token.to_s)
133
+ else
134
+ bump_subject(cache, subject_id(payload))
135
+ end
136
+ else
137
+ # A `_User` write gives a user id, but identity entries are
138
+ # keyed by session token and no reverse map exists. Bumping a
139
+ # per-user generation invalidates every one of that user's
140
+ # entries in O(1), including tokens this process has never
141
+ # resolved, and without Parse Server's master-key `_Session`
142
+ # query.
143
+ bump_subject(cache, subject_id(payload))
144
+ end
145
+ end
146
+ end
147
+
55
148
  private
56
149
 
57
150
  def install_role_triggers!(cache)
151
+ # `invalidator` is a captured LOCAL, not `self`. The registered block
152
+ # runs with `self` rebound to the payload, so a bare method call in
153
+ # its body would resolve against `Parse::Webhooks::Payload`. A local
154
+ # closes over correctly regardless of the receiver the dispatcher
155
+ # binds.
156
+ invalidator = self
58
157
  TRIGGERS[:role].map do |(type, class_name)|
59
158
  Parse::Webhooks.route(type, class_name) do |payload|
60
- guard do
61
- # A role write does not say which users are affected: membership
62
- # and hierarchy changes arrive as relation deltas on `users` and
63
- # `roles`, and the cached value is a flattened transitive closure,
64
- # so a parent-role change reaches the members of every child.
65
- # Clearing the whole plane is both correct and cheap under a
66
- # scoped SCAN. Parse Server does the same, for the same reason.
67
- cache.roles.clear
68
- # Stamp the epoch so a *foreign* role entry written before this
69
- # moment is rejected on read. Parse Server does not clear its own
70
- # role cache on a `_Role` delete, so without this the next read
71
- # would take its stale entry back and our clear would shorten
72
- # revocation by nothing.
73
- cache.roles.touch_epoch
74
- end
159
+ invalidator.handle_role_trigger(cache)
75
160
  true
76
161
  end
77
162
  [type, class_name]
@@ -79,43 +164,10 @@ module Parse
79
164
  end
80
165
 
81
166
  def install_identity_triggers!(cache)
167
+ invalidator = self
82
168
  TRIGGERS[:identity].map do |(type, class_name)|
83
169
  Parse::Webhooks.route(type, class_name) do |payload|
84
- guard do
85
- case type
86
- when :after_logout
87
- # The only trigger Parse Server permits on `_Session`. The
88
- # object's own sessionToken is scrubbed from the payload, but
89
- # the token is captured from the requesting user before
90
- # scrubbing, and for a logout that user *is* the session being
91
- # ended. A master-key logout carries no user, so fall back to
92
- # the generation bump.
93
- #
94
- # Pass the RAW token, not a pre-hashed digest.
95
- # `Parse::Cache::SubCache#invalidate` hashes its `key`
96
- # argument internally for the `:idn` family (see
97
- # `SubCache#logical_key`), the same way `#get` / `#set` do —
98
- # that is what makes a `set(raw_token, ...)` /
99
- # `get(raw_token)` pair round-trip. Hashing here first and
100
- # handing SubCache an already-hashed value made it hash the
101
- # digest a second time, landing on a key nothing had ever
102
- # written to, so logout silently failed to evict the entry.
103
- token = payload.respond_to?(:session_token) ? payload.session_token : nil
104
- if token && !token.to_s.empty?
105
- cache.identity.invalidate(token.to_s)
106
- else
107
- bump_subject(cache, subject_id(payload))
108
- end
109
- else
110
- # A `_User` write gives a user id, but identity entries are
111
- # keyed by session token and no reverse map exists. Bumping a
112
- # per-user generation invalidates every one of that user's
113
- # entries in O(1), including tokens this process has never
114
- # resolved, and without Parse Server's master-key `_Session`
115
- # query.
116
- bump_subject(cache, subject_id(payload))
117
- end
118
- end
170
+ invalidator.handle_identity_trigger(cache, type, payload)
119
171
  true
120
172
  end
121
173
  [type, class_name]
@@ -243,6 +243,10 @@ module Parse
243
243
  instance_variable_set ivar, val
244
244
  end
245
245
 
246
+ # Capture after lazy hydration, before a caller can mutate an
247
+ # object returned by this association getter.
248
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
249
+
246
250
  # Track association source for N+1 detection when returning an unfetched pointer
247
251
  # Uses a registry instead of setting instance variables on the pointer object
248
252
  if val.is_a?(Parse::Pointer) && val.pointer? && Parse.warn_on_n_plus_one
@@ -359,6 +359,9 @@ module Parse
359
359
 
360
360
  # Notifies the delegate that the collection changed.
361
361
  def notify_will_change!
362
+ if @delegate && @delegate.respond_to?(:_capture_transaction_state!, true)
363
+ @delegate.send(:_capture_transaction_state!)
364
+ end
362
365
  collection_will_change!
363
366
  forward "#{@key}_will_change!"
364
367
  end
@@ -502,6 +502,10 @@ module Parse
502
502
  val = instance_variable_get ivar
503
503
  end
504
504
 
505
+ # Capture before this getter materializes or returns a mutable
506
+ # proxy that can be changed in place.
507
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
508
+
505
509
  # if the result is not a collection proxy, then create a new one.
506
510
  unless val.is_a?(Parse::PointerCollectionProxy)
507
511
  results = []
@@ -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
 
@@ -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")
@@ -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)`
@@ -2042,6 +2044,8 @@ module Parse
2042
2044
  # caller intent to override.
2043
2045
  # @api private
2044
2046
  def acl_will_change!
2047
+ _capture_transaction_state!
2048
+
2045
2049
  # Only capture snapshot on the first change (before any modifications)
2046
2050
  unless defined?(@_acl_snapshot_before_change) && @_acl_snapshot_before_change
2047
2051
  # Deep copy the ACL by creating a new one from its JSON representation
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.7.0"
9
+ VERSION = "5.7.1"
10
10
  end
11
11
  end
@@ -132,6 +132,46 @@ module Parse
132
132
 
133
133
  class << self
134
134
 
135
+ # Whether an exception raised by one `after_*` handler prevents the
136
+ # remaining handlers for that same trigger from running.
137
+ #
138
+ # Only the accumulating, non-rejectable `after_*` triggers can have more
139
+ # than one handler (see {Parse::Webhooks::Registration#route}), so this
140
+ # governs `after_save`, `after_delete`, and `after_logout` and nothing
141
+ # else. `before_*` dispatch is untouched: a raise there is how a handler
142
+ # denies an operation, and it must continue to abort.
143
+ #
144
+ # Defaults to `true`, which is the historical behavior. Handlers are
145
+ # folded with `Array#map`, and `map` abandons the collection on the first
146
+ # raise, so a handler that raises silently prevents every handler
147
+ # registered after it from running. That ordering is not something an
148
+ # application fully controls: the SDK's own cache-invalidation triggers
149
+ # install during `Parse.setup` and therefore sit ahead of handlers
150
+ # registered by application files loaded later.
151
+ #
152
+ # Set to `false` to isolate handlers from each other, so that each one
153
+ # runs regardless of what an earlier one raised. The error is reported
154
+ # (a warning plus a `parse.webhooks.handler_error` notification) and
155
+ # dispatch continues.
156
+ #
157
+ # Either way, nothing is reverted. An `after_*` trigger fires once the
158
+ # write has already committed, so there is no version of this setting
159
+ # that can undo the save; the only question it answers is whether the
160
+ # remaining handlers still get to run.
161
+ #
162
+ # @example Keep one failing handler from starving the others
163
+ # Parse::Webhooks.abort_after_callbacks_on_error = false
164
+ #
165
+ # @return [Boolean]
166
+ attr_writer :abort_after_callbacks_on_error
167
+
168
+ # (see #abort_after_callbacks_on_error=)
169
+ # @return [Boolean]
170
+ def abort_after_callbacks_on_error
171
+ return @abort_after_callbacks_on_error unless @abort_after_callbacks_on_error.nil?
172
+ true
173
+ end
174
+
135
175
  # Allows support for web frameworks that support auto-reloading of source.
136
176
  # @!visibility private
137
177
  def reload!(args = {})
@@ -245,6 +285,60 @@ module Parse
245
285
  # block that declares a parameter (`do |payload| ... end`) or a splat
246
286
  # receives the payload.
247
287
  #
288
+ # Run every handler registered for one accumulating `after_*` trigger.
289
+ #
290
+ # `.last` is preserved as the composed result because Parse Server
291
+ # ignores the response body for these triggers and {#call_route}
292
+ # normalizes it anyway, so which handler's value survives is not
293
+ # observable.
294
+ #
295
+ # When {abort_after_callbacks_on_error} is false, a handler that raises
296
+ # is reported and skipped rather than taking the rest of the trigger down
297
+ # with it. Nothing is reverted in either mode: the write these triggers
298
+ # fire on has already committed.
299
+ #
300
+ # @param payload [Parse::Webhooks::Payload] the request payload.
301
+ # @param registry [Array<Proc>] the handlers, in registration order.
302
+ # @param type [Symbol] the trigger being dispatched.
303
+ # @return [Object] the last handler result.
304
+ def dispatch_composed(payload, registry, type)
305
+ return registry.map { |hook| invoke_handler(payload, hook) }.last if
306
+ abort_after_callbacks_on_error
307
+
308
+ last = nil
309
+ registry.each do |hook|
310
+ begin
311
+ last = invoke_handler(payload, hook)
312
+ rescue StandardError => e
313
+ report_handler_error(type, e)
314
+ end
315
+ end
316
+ last
317
+ end
318
+
319
+ # Report a handler failure that was isolated rather than propagated.
320
+ #
321
+ # The message is included because an application's own handler raised it
322
+ # and the application needs it to debug; this is not the SDK's internal
323
+ # `guard`, which deliberately omits messages that can carry a cache key.
324
+ #
325
+ # @param type [Symbol] the trigger being dispatched.
326
+ # @param error [StandardError] the raised error.
327
+ # @return [void]
328
+ def report_handler_error(type, error)
329
+ warn "[Parse::Webhooks] #{type} handler raised #{error.class}: #{error.message}; " \
330
+ "continuing with the remaining handlers " \
331
+ "(Parse::Webhooks.abort_after_callbacks_on_error is false)"
332
+ return unless defined?(ActiveSupport::Notifications)
333
+ begin
334
+ ActiveSupport::Notifications.instrument(
335
+ "parse.webhooks.handler_error", trigger: type, error: error.class.name,
336
+ )
337
+ rescue StandardError
338
+ nil
339
+ end
340
+ end
341
+
248
342
  # @param payload [Parse::Webhooks::Payload] the request payload (becomes `self`).
249
343
  # @param block [Proc] the registered handler block.
250
344
  # @return [Object] the handler's result value.
@@ -378,7 +472,10 @@ module Parse
378
472
  end
379
473
 
380
474
  if registry.is_a?(Array)
381
- result = registry.map { |hook| invoke_handler(payload, hook) }.last
475
+ # An Array registry only ever exists for the accumulating,
476
+ # non-rejectable `after_*` triggers, so isolating handlers here
477
+ # cannot affect `before_*` rejection semantics.
478
+ result = dispatch_composed(payload, registry, type)
382
479
  else
383
480
  result = invoke_handler(payload, registry)
384
481
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.0
4
+ version: 5.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin