parse-stack-next 5.2.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -1
@@ -25,7 +25,9 @@ module Parse
25
25
  original: nil, update: nil,
26
26
  query: nil, log: nil,
27
27
  objects: nil,
28
- triggerName: nil }.freeze
28
+ triggerName: nil,
29
+ event: nil, clients: nil, subscriptions: nil,
30
+ context: nil }.freeze
29
31
  include ::ActiveModel::Serializers::JSON
30
32
  # @!attribute [rw] master
31
33
  # @return [Boolean] whether the master key was used for this request.
@@ -64,6 +66,43 @@ module Parse
64
66
  attr_accessor :master, :user, :installation_id, :params, :function_name, :object, :trigger_name
65
67
  attr_accessor :query, :log, :objects
66
68
  attr_accessor :original, :update, :raw
69
+ # @!attribute [rw] event
70
+ # The LiveQuery event type for an +afterEvent+ trigger -- one of
71
+ # +"create"+, +"enter"+, +"update"+, +"leave"+, or +"delete"+ -- or
72
+ # +"connect"+ for a +beforeConnect+ trigger. +nil+ for every non-
73
+ # LiveQuery trigger. See {#after_event?} / {#before_connect?}.
74
+ # @return [String, nil]
75
+ # @!attribute [rw] clients
76
+ # Connection-global metadata sent on the LiveQuery +beforeConnect+ /
77
+ # +afterEvent+ triggers: the number of currently-connected LiveQuery
78
+ # clients. +nil+ for non-LiveQuery triggers.
79
+ # @return [Integer, nil]
80
+ # @!attribute [rw] subscriptions
81
+ # Connection-global metadata sent on the LiveQuery +beforeConnect+ /
82
+ # +afterEvent+ triggers: the number of active subscriptions. +nil+ for
83
+ # non-LiveQuery triggers.
84
+ # @return [Integer, nil]
85
+ attr_accessor :event, :clients, :subscriptions
86
+ # @!attribute [rw] context
87
+ # The caller-supplied context object threaded from the originating REST
88
+ # write or cloud-function call via the +X-Parse-Cloud-Context+ header.
89
+ # Parse Server includes this as a top-level +context+ key in trigger
90
+ # payloads (beforeSave/afterSave/etc.). Returns a Hash when present, or
91
+ # +nil+ when the originating request carried no context.
92
+ # @return [Hash, nil]
93
+ attr_accessor :context
94
+ # @!attribute [r] session_token
95
+ # The caller's live Parse session token, captured from the incoming
96
+ # webhook payload (`user.sessionToken`) before credentials are scrubbed
97
+ # from {#user} / {#object} / {#original} / {#update}. Present only when
98
+ # the originating request was made by a logged-in user -- a master-key
99
+ # request carries no user and no token, so this is +nil+. It is
100
+ # intentionally NOT one of {ATTRIBUTES}, so it never appears in
101
+ # {#as_json} or in the redacted request log. Reach for it (or the
102
+ # higher-level {#user_client} / {#user_agent}) only when a handler
103
+ # deliberately wants to act on the server as the calling user.
104
+ # @return [String, nil]
105
+ attr_reader :session_token
67
106
  # @!visibility private
68
107
  attr_accessor :webhook_class
69
108
  alias_method :installationId, :installation_id
@@ -73,11 +112,44 @@ module Parse
73
112
  # You would normally never create a {Parse::Webhooks::Payload} object since it is automatically
74
113
  # provided to you when using Parse::Webhooks.
75
114
  # @see Parse::Webhooks
76
- def initialize(hash = {})
115
+ # @param hash [String, Hash] the raw webhook body (JSON string or Hash).
116
+ # @param webhook_class [String, nil] the Parse class name derived from the
117
+ # webhook URL path (`<endpoint>/<triggerName>/<className>`). This is the
118
+ # ONLY authoritative source of the class for beforeFind/afterFind
119
+ # triggers — Parse Server omits `className` from the find payload body
120
+ # entirely (the matched `objects` carry no `className` and there is no
121
+ # top-level one). Threading it here lets `parse_class` resolve (so find
122
+ # triggers route) and lets `:vector` columns be stripped from afterFind
123
+ # `objects`. For save/delete triggers the path className equals the
124
+ # body's, so it is consistent; for functions it is nil.
125
+ def initialize(hash = {}, webhook_class = nil)
77
126
  hash = JSON.parse(hash, max_nesting: 20) if hash.is_a?(String)
78
127
  hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
79
128
  @raw = hash
129
+ # Set BEFORE the vector scrub below so the route-derived class is
130
+ # available to strip :vector columns from afterFind objects (whose
131
+ # body carries no className of its own).
132
+ @webhook_class = webhook_class.to_s if webhook_class && !webhook_class.to_s.empty?
80
133
  @master = hash[:master]
134
+ # Capture the caller's session token from the *unscrubbed* user hash
135
+ # before scrub_credentials strips it below. Parse Server includes
136
+ # `user.sessionToken` on every trigger fired by a logged-in caller
137
+ # (it is absent for master-key-originated requests). Pulling it aside
138
+ # here -- rather than leaving it in @user -- keeps it out of any object
139
+ # a handler might persist and out of #as_json / the request log, while
140
+ # still letting a handler opt in to acting as the calling user via
141
+ # #session_token / #user_client / #user_agent.
142
+ @session_token = self.class.extract_session_token(hash[:user])
143
+ # LiveQuery beforeConnect/beforeSubscribe carry the caller's session
144
+ # token at the TOP LEVEL (not nested under `user`), because no user is
145
+ # resolved yet when the trigger fires. Capture it here -- with the same
146
+ # "set it aside, keep it out of as_json / the log" treatment as the
147
+ # nested form -- so #user_client / #user_agent can act as the caller.
148
+ # It is intentionally NOT one of ATTRIBUTES.
149
+ if @session_token.nil?
150
+ top_token = hash[:session_token].to_s.strip
151
+ @session_token = top_token unless top_token.empty?
152
+ end
81
153
  # Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
82
154
  # Parse Server and, when a webhook key is configured (the default; see
83
155
  # Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
@@ -85,7 +157,8 @@ module Parse
85
157
  # server-authoritative state. A handler is meant to receive the full
86
158
  # object -- createdAt/updatedAt, ACL, internal fields and all. The only
87
159
  # thing stripped here is genuine credential material a handler never
88
- # legitimately needs to read (live session tokens, offline-crackable
160
+ # legitimately needs to read inline (live session tokens -- captured
161
+ # above for opt-in user-scoped clients first -- and offline-crackable
89
162
  # password hashes); see WEBHOOK_TRIGGER_CREDENTIAL_KEYS. Protection
90
163
  # against *persisting* forged privileged fields lives on the write path
91
164
  # (changes_payload emits only declared, dirty-tracked properties), not on
@@ -103,14 +176,42 @@ module Parse
103
176
  @params = hash[:params]
104
177
  @params = @params.with_indifferent_access if @params.is_a?(Hash)
105
178
  @function_name = hash[:function_name]
106
- @object = self.class.scrub_credentials(hash[:object])
107
179
  @trigger_name = hash[:trigger_name]
108
- @original = self.class.scrub_credentials(hash[:original])
109
- @update = self.class.scrub_credentials(hash[:update]) || {}
110
- # Added for beforeFind and afterFind triggers
180
+ # Resolve the model class once so :vector columns can be stripped from
181
+ # every object-shaped payload (see scrub_vector_columns). Credentials
182
+ # are scrubbed first, then vectors. The route-derived @webhook_class is
183
+ # authoritative and preferred — it is the only class source for
184
+ # afterFind (whose body carries no className anywhere); for save/delete
185
+ # it equals the body's className. Falls back to the object/original
186
+ # className for older callers that don't supply a route class.
187
+ vec_klass = self.class.resolve_klass_by_name(@webhook_class) ||
188
+ self.class.resolve_vector_klass(hash[:object], hash[:original])
189
+ @object = self.class.scrub_vector_columns(self.class.scrub_credentials(hash[:object]), vec_klass)
190
+ @original = self.class.scrub_vector_columns(self.class.scrub_credentials(hash[:original]), vec_klass)
191
+ @update = self.class.scrub_vector_columns(self.class.scrub_credentials(hash[:update]), vec_klass) || {}
192
+ # Added for beforeFind and afterFind triggers. afterFind objects are all
193
+ # of one class but carry no className of their own, so the route-derived
194
+ # vec_klass is the only way to strip their :vector columns.
111
195
  @query = hash[:query]
112
- @objects = hash[:objects] || []
196
+ # LiveQuery connection metadata. `event` is the afterEvent event type
197
+ # (create/enter/update/leave/delete) or "connect" for beforeConnect;
198
+ # `clients`/`subscriptions` are connection-global counts. All nil for
199
+ # the object / auth triggers. These are plain scalars (no credential
200
+ # material), so they pass through unscrubbed.
201
+ @event = hash[:event]
202
+ @clients = hash[:clients]
203
+ @subscriptions = hash[:subscriptions]
204
+ @objects = Array(hash[:objects]).map do |o|
205
+ self.class.scrub_vector_columns(self.class.scrub_credentials(o), vec_klass)
206
+ end
113
207
  @log = hash[:log]
208
+ # Caller-supplied context object threaded via X-Parse-Cloud-Context.
209
+ # This is caller metadata (not a credential), so it passes through
210
+ # without scrubbing — mirroring the treatment of @query and @log.
211
+ @context = hash[:context]
212
+ # Blocks registered by a handler via #after_response / #defer, to run
213
+ # after the webhook response has been sent (drained by the Rack app).
214
+ @deferred_callbacks = []
114
215
  end
115
216
 
116
217
  # @!visibility private
@@ -144,11 +245,95 @@ module Parse
144
245
  end
145
246
  end
146
247
 
248
+ # @!visibility private
249
+ # Resolve the Parse::Object subclass for a webhook payload from the
250
+ # `className` of the first object-shaped hash given. Returns nil when
251
+ # no class name is present or no matching model is registered (the
252
+ # caller then skips vector stripping — fail-open is acceptable here:
253
+ # an unregistered class has no declared `:vector` columns to strip).
254
+ def self.resolve_vector_klass(*candidates)
255
+ candidates.each do |obj|
256
+ next unless obj.is_a?(Hash)
257
+ name = obj["className"] || obj[:className]
258
+ next if name.nil? || name.to_s.empty?
259
+ klass = resolve_klass_by_name(name)
260
+ return klass if klass
261
+ end
262
+ nil
263
+ end
264
+
265
+ # @!visibility private
266
+ # Resolve a registered Parse::Object subclass from a bare class-name
267
+ # string (e.g. the route-derived @webhook_class). Returns nil for a blank
268
+ # name or an unregistered class (the caller then skips vector stripping —
269
+ # fail-open, as an unregistered class has no declared :vector columns).
270
+ def self.resolve_klass_by_name(name)
271
+ return nil if name.nil? || name.to_s.empty?
272
+ klass = (Parse::Object.find_class(name.to_s) rescue nil)
273
+ klass.respond_to?(:fields) ? klass : nil
274
+ end
275
+
276
+ # @!visibility private
277
+ # Returns a copy of +obj+ with the model's declared `:vector`
278
+ # columns removed. Embeddings are large dense float arrays that leak
279
+ # ML signal; a webhook handler has no reason to receive them, and
280
+ # leaving them in bloats logs and any object a handler re-persists.
281
+ # Mirrors the `as_json` default (vectors omitted) — a class that opts
282
+ # into `vector_visibility :public` keeps its vectors here too.
283
+ #
284
+ # `klass` may be passed explicitly (so changed-only payloads like
285
+ # `update`, which carry no `className`, are still scrubbed using the
286
+ # class resolved from the sibling `object`/`original` hash); when nil
287
+ # it is resolved from the hash's own `className`.
288
+ # Pass-through for non-Hash input (and nil).
289
+ def self.scrub_vector_columns(obj, klass = nil)
290
+ return obj unless obj.is_a?(Hash)
291
+ klass ||= resolve_vector_klass(obj)
292
+ return obj if klass.nil?
293
+ if klass.respond_to?(:vectors_public_by_default?) && klass.vectors_public_by_default?
294
+ return obj
295
+ end
296
+ vector_fields = klass.fields(:vector).keys.map(&:to_s)
297
+ return obj if vector_fields.empty?
298
+ field_map = klass.respond_to?(:field_map) ? klass.field_map : {}
299
+ wire = vector_fields.map { |f| (field_map[f.to_sym] || f).to_s }
300
+ denied = (vector_fields + wire)
301
+ obj.reject { |k, _| denied.include?(k.to_s) }
302
+ end
303
+
304
+ # @!visibility private
305
+ # Pulls the caller's session token out of the (unscrubbed) +user+ hash.
306
+ # Parse Server sends it as the camelCase string key +sessionToken+; this
307
+ # tolerates a symbol key and the snake_case form too, mirroring the
308
+ # leniency in +scrub_credentials+. Returns +nil+ for a blank token or a
309
+ # non-Hash / absent user (a master-key request has no user).
310
+ def self.extract_session_token(user_hash)
311
+ return nil unless user_hash.is_a?(Hash)
312
+ token = user_hash["sessionToken"] || user_hash[:sessionToken] ||
313
+ user_hash["session_token"] || user_hash[:session_token]
314
+ token = token.to_s.strip
315
+ token.empty? ? nil : token
316
+ end
317
+
147
318
  # @return [ATTRIBUTES]
148
319
  def attributes
149
320
  ATTRIBUTES
150
321
  end
151
322
 
323
+ # Redacted inspection. The default Ruby `#inspect` would dump every ivar,
324
+ # including the captured `@session_token` and the *pre-scrub* `@raw` hash
325
+ # (which still holds the caller's sessionToken and any password hashes).
326
+ # That is exactly the surface an error reporter or a stray `p payload`
327
+ # hits, so show only non-sensitive routing fields and a boolean for the
328
+ # token's presence. Use #as_json / the individual accessors for the
329
+ # (already credential-scrubbed) object data.
330
+ def inspect
331
+ "#<#{self.class.name} trigger=#{@trigger_name.inspect} " \
332
+ "function=#{@function_name.inspect} class=#{parse_class.inspect} " \
333
+ "id=#{parse_id.inspect} master=#{@master ? true : false} " \
334
+ "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
335
+ end
336
+
152
337
  # Method to print to standard that utilizes the an internal id to make it easier
153
338
  # to trace incoming requests.
154
339
  def wlog(s)
@@ -170,6 +355,51 @@ module Parse
170
355
  @master.present?
171
356
  end
172
357
 
358
+ # true if this payload carried a caller session token -- i.e. the
359
+ # originating request was made by a logged-in user rather than the
360
+ # master key, so {#user_client} / {#user_agent} can act as that user.
361
+ # @return [Boolean]
362
+ def session_token?
363
+ !@session_token.nil?
364
+ end
365
+
366
+ # An opt-in, user-scoped {Parse::Client} for acting on the server as the
367
+ # webhook's calling user. It mirrors the default client's connection
368
+ # settings (+server_url+, +application_id+, +api_key+) but carries NO
369
+ # master key and BINDS the caller's {#session_token}, so every request it
370
+ # makes -- with no further ceremony -- is authorized by Parse Server as
371
+ # that user: ACL, CLP and +protectedFields+ are all enforced. (A
372
+ # `Parse.with_session` block still overrides the bound token if you need
373
+ # to act as someone else within a call.) Memoized per payload, since each
374
+ # webhook delivery carries a distinct token.
375
+ # @return [Parse::Client, nil] +nil+ when the payload carried no token.
376
+ def user_client
377
+ return nil if @session_token.nil?
378
+ @user_client ||= Parse::Client.client.become(@session_token)
379
+ end
380
+
381
+ # An opt-in, non-master {Parse::Agent} scoped to the webhook caller's
382
+ # session token. Because its client has no master key and it is built
383
+ # with a non-empty +session_token:+, the agent runs in CLIENT MODE:
384
+ # every tool/query routes through a path Parse Server (or the SDK's own
385
+ # ACL/CLP enforcement layer) authorizes as the calling user, with no
386
+ # master-key fallback to silently bypass row-level security. This is the
387
+ # handle to use when a handler should read or act strictly within the
388
+ # caller's permissions. Additional agent options (e.g.
389
+ # +permissions: :readwrite+) may be passed through.
390
+ # @param opts [Hash] extra keyword args forwarded to {Parse::Agent#initialize}.
391
+ # @return [Parse::Agent, nil] +nil+ when the payload carried no token.
392
+ def user_agent(**opts)
393
+ return nil if @session_token.nil?
394
+ require_relative "../agent" unless defined?(Parse::Agent)
395
+ # Strip the two identity kwargs from the passthrough: a Ruby double-splat
396
+ # that repeats an explicit keyword WINS, so user_agent(client: master)
397
+ # or user_agent(session_token: other) would otherwise silently defeat the
398
+ # whole point (scoping to the caller). The scoping is non-negotiable here.
399
+ opts = opts.except(:session_token, :client)
400
+ Parse::Agent.new(session_token: @session_token, client: user_client, **opts)
401
+ end
402
+
173
403
  # @return [String] the name of the Parse class for this request.
174
404
  def parse_class
175
405
  return @webhook_class if @webhook_class.present?
@@ -230,6 +460,73 @@ module Parse
230
460
  trigger? && @trigger_name.to_sym == :afterFind
231
461
  end
232
462
 
463
+ # true if this is a beforeLogin webhook trigger request.
464
+ #
465
+ # NOTE: a +beforeLogin+ payload carries the user being authenticated as
466
+ # {#object} / {#parse_object} (a +_User+), NOT as {#user} -- the caller is
467
+ # not yet authenticated when the trigger fires, so {#user} is +nil+. (By
468
+ # +afterLogin+ both are populated and equal.) Reach for {#parse_object} to
469
+ # inspect the logging-in user during +beforeLogin+.
470
+ def before_login?
471
+ trigger? && @trigger_name.to_sym == :beforeLogin
472
+ end
473
+
474
+ # true if this is a afterLogin webhook trigger request.
475
+ def after_login?
476
+ trigger? && @trigger_name.to_sym == :afterLogin
477
+ end
478
+
479
+ # true if this is a afterLogout webhook trigger request. The logged-out
480
+ # session is carried as {#object} / {#parse_object} (a +_Session+).
481
+ def after_logout?
482
+ trigger? && @trigger_name.to_sym == :afterLogout
483
+ end
484
+
485
+ # true if this is a beforePasswordResetRequest webhook trigger request.
486
+ # The target user is carried as {#object} / {#parse_object} (a +_User+).
487
+ def before_password_reset_request?
488
+ trigger? && @trigger_name.to_sym == :beforePasswordResetRequest
489
+ end
490
+
491
+ # true if this is a LiveQuery beforeConnect webhook trigger request.
492
+ # Connection-global: carries no {#object}; the className is the +@Connect+
493
+ # sentinel and the caller's token (if any) is in {#session_token}.
494
+ def before_connect?
495
+ trigger? && @trigger_name.to_sym == :beforeConnect
496
+ end
497
+
498
+ # true if this is a LiveQuery beforeSubscribe webhook trigger request.
499
+ # Shaped like beforeFind: carries a {#query} (see {#parse_query}) and the
500
+ # className comes from the request path, not the body.
501
+ def before_subscribe?
502
+ trigger? && @trigger_name.to_sym == :beforeSubscribe
503
+ end
504
+
505
+ # true if this is a LiveQuery afterEvent webhook trigger request. The
506
+ # event type (create/enter/update/leave/delete) is in {#event}.
507
+ def after_event?
508
+ trigger? && @trigger_name.to_sym == :afterEvent
509
+ end
510
+
511
+ # true if this is one of the authentication-side triggers
512
+ # (beforeLogin / afterLogin / afterLogout / beforePasswordResetRequest).
513
+ # These carry a +_User+ / +_Session+ as {#object} but are NOT object
514
+ # save/delete triggers: no ActiveModel save/create/destroy callbacks run
515
+ # for them, and Parse Server ignores the response body (the only way to
516
+ # affect a +before*+ one is to deny it -- see the webhook router).
517
+ def auth_trigger?
518
+ before_login? || after_login? || after_logout? || before_password_reset_request?
519
+ end
520
+
521
+ # true if this is one of the LiveQuery triggers (beforeConnect /
522
+ # beforeSubscribe / afterEvent). Parse Server delivers these over an HTTP
523
+ # webhook only in a co-located single-process LiveQuery setup;
524
+ # +beforeConnect+ in particular carries a live client and is effectively
525
+ # in-process-only. See the webhooks guide.
526
+ def live_query_trigger?
527
+ before_connect? || before_subscribe? || after_event?
528
+ end
529
+
233
530
  # true if this request is a trigger that contains an object.
234
531
  def object?
235
532
  trigger? && @object.present?
@@ -321,9 +618,57 @@ module Parse
321
618
  return o
322
619
  end
323
620
 
324
- # afterSave on a CREATE (and every other trigger): the full object as the
325
- # server sent it. createdAt/updatedAt survive (only credentials are
326
- # scrubbed), so `new?` / `existed?` read correctly.
621
+ # afterSave on a CREATE: there is no prior persisted state, so every
622
+ # populated data field is new. Build symmetry with the UPDATE path above
623
+ # by seeding the identity / system fields (objectId, timestamps, ACL,
624
+ # className, plus the credential / permission keys) into a pristine
625
+ # object, then overlaying the full object with dirty tracking. Because
626
+ # the overlay's protected_set is exactly the seed key set, the system
627
+ # fields come ONLY from the clean seed and the overlay touches ONLY
628
+ # declared data properties (nil -> value -> changed) -- so `*_changed?`
629
+ # reports every field the create populated while createdAt / updatedAt /
630
+ # ACL stay clean. This lets handlers key off dirty tracking uniformly
631
+ # across create and update (e.g. building a sync payload from changed
632
+ # fields). Credentials / _rperm / _wperm / authData / roles are filtered
633
+ # from the overlay (seeded read-only, never marked changed), and an
634
+ # after-trigger response is only true/false, so nothing here can persist
635
+ # a forged field.
636
+ if after_save? && @object.is_a?(Hash)
637
+ seed_keys = Parse::Properties::PROTECTED_INITIALIZE_KEYS +
638
+ %w[objectId createdAt updatedAt ACL className __type]
639
+ seed = @object.slice(*seed_keys)
640
+ o = Parse::Object.build seed, parse_class
641
+ # `build` applies declared `default:` values onto the seed and then
642
+ # clears changes, baking each default into the "pristine" baseline.
643
+ # Without correction, the overlay's dirty guard (`unless val ==
644
+ # current`) would SUPPRESS marking any create value equal to its
645
+ # default (e.g. status: "draft", count: 0, archived: false), silently
646
+ # defeating this branch's whole purpose. Reset the default-bearing
647
+ # ivars to nil for the fields the overlay is about to set, then
648
+ # re-clear, so the overlay's guard sees a differing current ivar and
649
+ # marks every populated data field changed. (`*_changed?` / `changes`
650
+ # / `changed` then report the field; for a defaulted field the
651
+ # reported prior value is the default rather than nil, since the
652
+ # getter re-derives the default — its prior *effective* value.)
653
+ # `defaults_list` never contains the seeded system fields
654
+ # (objectId/createdAt/updatedAt/ACL), so this cannot disturb them;
655
+ # defaults for fields ABSENT from the payload are left intact so their
656
+ # value still reads through.
657
+ fmap = o.class.respond_to?(:field_map) ? o.class.field_map : {}
658
+ o.class.defaults_list.each do |k|
659
+ wire = (fmap[k] || k).to_s
660
+ next unless @object.key?(wire) || @object.key?(k.to_s)
661
+ o.instance_variable_set(:"@#{k}", nil)
662
+ end
663
+ o.clear_changes!
664
+ o.apply_attributes! @object, dirty_track: true, protected_set: seed_keys
665
+ return o
666
+ end
667
+
668
+ # Every other trigger (afterDelete, afterFind, and any before* path that
669
+ # did not match above): the full object as the server sent it.
670
+ # createdAt/updatedAt survive (only credentials are scrubbed), so
671
+ # `new?` / `existed?` read correctly.
327
672
  Parse::Object.build(@object, parse_class)
328
673
  end
329
674
 
@@ -340,6 +685,49 @@ module Parse
340
685
  raise Parse::Webhooks::ResponseError, msg
341
686
  end
342
687
 
688
+ # Register a block to run **after** this webhook's response has been sent
689
+ # to Parse Server, off the client's critical path. Use it to do work that
690
+ # should not add latency to the save/function the client is waiting on —
691
+ # search indexing, cache warming, fan-out notifications.
692
+ #
693
+ # The handler still returns its value synchronously (the response Parse
694
+ # Server acts on); the deferred block runs afterward. When the SDK is
695
+ # mounted under a server that supports `rack.after_reply` (Puma, Unicorn)
696
+ # the block runs once the response is flushed to the socket, on the same
697
+ # worker thread; otherwise it runs in a detached thread. Each block is
698
+ # isolated, so one raising neither affects the response nor the others.
699
+ #
700
+ # Parse::Webhooks.route :after_save, :Post do
701
+ # post = parse_object
702
+ # after_response { SearchIndex.reindex(post.id) }
703
+ # post
704
+ # end
705
+ #
706
+ # `self` inside the block is this payload (it closes over the handler's
707
+ # scope), so `parse_object`, `params`, etc. remain available. Note the
708
+ # block runs in-process and does not survive a worker restart — for work
709
+ # that *must* happen, hand it to a durable job queue instead. Deferred
710
+ # callbacks fire only when the payload is processed through the mounted
711
+ # `Parse::Webhooks` Rack app.
712
+ #
713
+ # @yield the work to run after the response is sent.
714
+ # @return [Boolean] true if a block was registered.
715
+ def after_response(&block)
716
+ return false unless block_given?
717
+ @deferred_callbacks ||= []
718
+ @deferred_callbacks << block
719
+ true
720
+ end
721
+ alias_method :defer, :after_response
722
+
723
+ # @!visibility private
724
+ # The blocks registered via {#after_response}; drained by the Rack app
725
+ # ({Parse::Webhooks.dispatch_deferred}) after the response is finished.
726
+ # @return [Array<Proc>]
727
+ def deferred_callbacks
728
+ @deferred_callbacks ||= []
729
+ end
730
+
343
731
  # @return [Parse::Query] the Parse query for a beforeFind trigger.
344
732
  def parse_query
345
733
  return nil unless parse_class.present? && @query.is_a?(Hash)