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
@@ -173,6 +173,58 @@ set the same kwargs on the query for chainable composition.
173
173
  Related: `first_direct(n)` for the first N rows, `count_direct` for a
174
174
  count-only query. Both accept the same auth kwargs.
175
175
 
176
+ #### Field projection: `keys` and `exclude_keys`
177
+
178
+ The two field-selection options behave differently on the direct path
179
+ because MongoDB's `$project` is an allowlist, not a denylist:
180
+
181
+ - **`keys` (allowlist)** compiles to a `$project` stage in the direct
182
+ pipeline, so the projection runs server-side in MongoDB — only the
183
+ named fields (plus the reserved envelope: `_id`, `_created_at`,
184
+ `_updated_at`, `_acl`) leave the database.
185
+
186
+ - **`exclude_keys` (denylist)** has no `$project` equivalent, so Parse
187
+ Stack honors it as a **post-fetch sanitize**: the pipeline is
188
+ unchanged, and the SDK recursively strips every key with a matching
189
+ name from the decoded results in Ruby. The fields still travel from
190
+ MongoDB to the client — this is a result-shaping convenience, not a
191
+ data-minimization or access-control boundary.
192
+
193
+ ```ruby
194
+ # Allowlist — projected server-side via $project
195
+ Song.query.keys(:title, :artist).results_direct
196
+
197
+ # Denylist — stripped client-side after fetch
198
+ Song.query.exclude_keys(:internal_notes).results_direct
199
+ ```
200
+
201
+ Two consequences specific to the direct path:
202
+
203
+ 1. **Recursive by name.** `exclude_keys(:name)` removes `name` at every
204
+ depth, including inside included/nested objects — so a query that
205
+ includes a pointer also strips the pointed-to object's `name`. This
206
+ is broader than Parse Server's REST `excludeKeys`, which is
207
+ path-scoped (top-level or dotted) and would leave the nested field
208
+ intact. The same query can therefore return different shapes on the
209
+ REST and direct paths.
210
+
211
+ 2. **Reserved fields are never stripped.** `objectId`, `className`,
212
+ `__type`, `createdAt`, `updatedAt`, `ACL`, and their Mongo
213
+ storage-form names (`_id`, `_created_at`, `_updated_at`, `_acl`) are
214
+ always retained, so excluding one of them is a no-op rather than a
215
+ way to break object reconstruction.
216
+
217
+ The sanitize applies to the object/decoded result paths
218
+ (`results_direct`, `first_direct`, and the auto-promoted
219
+ `$inQuery`/`$notInQuery` aggregation). The raw aggregation accessor
220
+ (`aggregate(...).raw`) returns documents untouched.
221
+
222
+ Because `exclude_keys` here is a projection convenience and not an
223
+ ACL/CLP/`protectedFields` boundary, the security contract in
224
+ [Security](#security) is unaffected — to keep a field from leaving the
225
+ database, use `keys` (allowlist) or `protectedFields`, not
226
+ `exclude_keys`.
227
+
176
228
  ### `Query#aggregate(pipeline, mongo_direct: true)`
177
229
 
178
230
  ```ruby
@@ -233,9 +285,35 @@ raw = Parse::MongoDB.find(
233
285
  ```
234
286
 
235
287
  Convenience wrapper around `db.find`. Accepts `limit:`, `skip:`, `sort:`,
236
- `projection:`, `max_time_ms:`. When `:limit` is omitted the call applies
288
+ `projection:`, `hint:`, `max_time_ms:`. When `:limit` is omitted the call applies
237
289
  `DEFAULT_FIND_LIMIT = 1000` and warns; pass `limit: 0` to opt out.
238
290
 
291
+ ### Forcing an index with `hint`
292
+
293
+ When the query planner picks a sub-optimal index on a large collection,
294
+ `Query#hint` forces a specific one. It applies on **both** paths — the REST body
295
+ (`hint` parameter, Parse Server 7.4.0+) and the mongo-direct path — so a plan you
296
+ diagnosed with `Query#explain` can be corrected without dropping to `mongosh`.
297
+
298
+ ```ruby
299
+ # Diagnose, then force the index, on the mongo-direct path:
300
+ Post.query(:status => "published").order(:created_at.desc).hint("status_1_created_at_-1")
301
+ .results_direct
302
+
303
+ # A key pattern works too:
304
+ Post.query(:status => "published").hint({ "status" => 1, "createdAt" => -1 }).count_direct
305
+ ```
306
+
307
+ On the mongo-direct path the hint is forwarded to the driver as the Mongo `hint`
308
+ option: `results_direct` / `count_direct` / `distinct_direct` pass it to
309
+ `Parse::MongoDB.aggregate` (`hint:` → the aggregation `hint` option), and the
310
+ primitives `Parse::MongoDB.aggregate(..., hint:)` and
311
+ `Parse::MongoDB.find(..., hint:)` accept it directly. The index name (a `String`)
312
+ or a key pattern (`Hash`) are both accepted; an unknown index name is rejected by
313
+ MongoDB, which is the intended fail-fast signal that the hint is stale.
314
+
315
+ `hint` is unset by default (the planner chooses); it is purely an override.
316
+
239
317
  ### Geo queries
240
318
 
241
319
  Three geo query constraints land in v4.4.0 alongside a direct
@@ -620,6 +698,20 @@ ACL/CLP enforcement if the SDK applies it.
620
698
  As of **v4.4.0**, the SDK applies that enforcement on the mongo-direct
621
699
  path when the caller supplies a scope. Five layers compose:
622
700
 
701
+ > **Atlas index entry points share this enforcement.** The Atlas-index
702
+ > stages (`$vectorSearch`, `$search`, `$rankFusion`) must be stage 0 of
703
+ > their pipeline, so they cannot route through `Parse::MongoDB.aggregate`
704
+ > (which prepends an ACL `$match` at stage 0). `Parse::VectorSearch.search`
705
+ > (`find_similar`), `Parse::AtlasSearch.search`, and
706
+ > `Parse::VectorSearch::Hybrid` (`Class.hybrid_search`, v5.4.0) therefore
707
+ > reproduce the same enforcement chain **inline** — the ACL `_rperm`
708
+ > `$match` is appended AFTER the index stage, and CLP / `protectedFields` /
709
+ > the internal-fields denylist run post-fetch — so the same scope kwargs
710
+ > (`session_token:` / `acl_user:` / `acl_role:` / `master:`) and the same
711
+ > contract apply. Hybrid search fuses two independently-enforced branches,
712
+ > so fused rows are already access-filtered. `$rankFusion` was added to the
713
+ > strict-mode allowlist (Layer 1) in v5.4.0 for the opt-in native path.
714
+
623
715
  ### Layer 1: Pipeline-security denylist (always on)
624
716
 
625
717
  `Parse::PipelineSecurity` refuses dangerous operators at any depth in
data/docs/usage_guide.md CHANGED
@@ -83,10 +83,20 @@ Song.query.order(:plays.desc).skip(10).limit(20).results
83
83
  # Include related objects
84
84
  Song.all(includes: [:album, :comments])
85
85
 
86
- # Select specific fields
86
+ # Select specific fields (allowlist)
87
87
  Song.all(keys: [:title, :artist])
88
+
89
+ # Omit specific fields (denylist)
90
+ Song.query.exclude_keys(:internal_notes).results
88
91
  ```
89
92
 
93
+ > On the mongo-direct read path, `keys` is projected server-side while
94
+ > `exclude_keys` is applied as a recursive post-fetch sanitize (it strips
95
+ > matching field names at every depth and never removes reserved fields
96
+ > such as `objectId`). See the
97
+ > [Direct MongoDB Integration Guide](mongodb_direct_guide.md) for the
98
+ > exact semantics and how it differs from the REST path.
99
+
90
100
  ## Aggregation
91
101
 
92
102
  ```ruby
@@ -0,0 +1,418 @@
1
+ # Cloud Code Webhooks Guide
2
+
3
+ Webhooks are how `parse-stack-next` runs **server-side** trigger logic. They are
4
+ the bridge between Parse Server and your Ruby code: Parse Server calls back into
5
+ a Ruby Rack app on a matching trigger, and your model's ActiveModel callbacks
6
+ (and any webhook blocks) run there.
7
+
8
+ This is a server-side-only concern. A pure client (or a server with no
9
+ registered webhooks) runs all of its trigger logic locally in ActiveModel and
10
+ nothing inside Parse Server.
11
+
12
+ ## Why register a webhook at all
13
+
14
+ A `Parse::Object`'s ActiveModel callbacks run in the process that initiates the
15
+ save:
16
+
17
+ - A **Ruby-initiated** save (this SDK) runs `before_save`, `after_create`, etc.
18
+ locally, before/after the REST call.
19
+ - A save from a **non-Ruby client** — the JS/Swift SDKs, a raw REST call, or the
20
+ Parse Dashboard — never touches your Ruby process. That trigger logic is
21
+ simply skipped server-side.
22
+
23
+ Registering a webhook closes that gap. Once Parse Server has a `beforeSave`
24
+ webhook for a class, it calls your Ruby app on every save from every client, and
25
+ your callbacks run server-side for all of them.
26
+
27
+ **The rule:** your ActiveModel logic applies to non-Ruby clients **only if the
28
+ webhook is registered.**
29
+
30
+ ## ActiveModel hooks vs Parse Server triggers
31
+
32
+ The SDK exposes the full ActiveModel lifecycle on every `Parse::Object`. Parse
33
+ Server, separately, exposes a fixed set of webhook trigger types. They are not
34
+ one-to-one — the SDK maps between them.
35
+
36
+ ### ActiveModel callbacks (Ruby side)
37
+
38
+ | Callback | Fires |
39
+ |----------|-------|
40
+ | `before_validation` / `after_validation` | around local validation |
41
+ | `before_save` / `after_save` | around every save (create **and** update) |
42
+ | `before_create` / `after_create` | around the first save of a new object |
43
+ | `before_update` / `after_update` | around saves of an existing object |
44
+ | `before_destroy` / `after_destroy` | around delete |
45
+
46
+ ### Parse Server webhook trigger types (server side)
47
+
48
+ | Trigger | className | Notes |
49
+ |---------|-----------|-------|
50
+ | `beforeSave` / `afterSave` | a class | create **and** update |
51
+ | `beforeDelete` / `afterDelete` | a class | |
52
+ | `beforeFind` / `afterFind` | a class | |
53
+ | `beforeLogin` / `afterLogin` | `_User` | login-side hooks |
54
+ | `afterLogout` | `_Session` | |
55
+ | `beforePasswordResetRequest` | `_User` | |
56
+ | `beforeSave` / `afterSave` / `beforeDelete` / `beforeFind` / `afterFind` | `@File` | file triggers |
57
+ | `beforeConnect` | `@Connect` | LiveQuery connection (connection-global) |
58
+ | `beforeSubscribe` / `afterEvent` | a class | LiveQuery subscription / events |
59
+
60
+ ### How they relate
61
+
62
+ - **`beforeSave` / `afterSave` carry the create variants.** Parse Server has **no
63
+ `beforeCreate` / `afterCreate` trigger** — it rejects them. The SDK runs your
64
+ `before_create` / `after_create` callbacks *inside* the `beforeSave` /
65
+ `afterSave` handler, gated on whether the object is new. So **registering a
66
+ `beforeSave` webhook enables both `before_save` and `before_create`**;
67
+ registering `afterSave` enables both `after_save` and `after_create`.
68
+
69
+ Asking for a create webhook fails fast with guidance:
70
+
71
+ ```ruby
72
+ Post.webhook(:after_create) { … }
73
+ # ArgumentError: There is no after_create webhook. Register `webhook :after_save`
74
+ # instead — your after_create ActiveModel callbacks run inside the after_save
75
+ # handler for new objects (registering after_save enables BOTH the after_save
76
+ # and after_create callbacks).
77
+ ```
78
+
79
+ - **Trigger order is honored.** Within the save handler the SDK runs callbacks in
80
+ ActiveModel order: `before_save` then `before_create` on the way in,
81
+ `after_create` then `after_save` on the way out.
82
+
83
+ - **`@File` and `@Connect` are pseudo-classes.** File triggers register against
84
+ the `@File` className; the connection-global LiveQuery trigger uses `@Connect`.
85
+ The SDK accepts both for the full register/fetch/delete lifecycle.
86
+
87
+ - **`beforeFind` / `afterFind` are result-side, not object-side.** Unlike the
88
+ save/delete triggers, a find payload carries no single `object` — `beforeFind`
89
+ exposes the incoming `query` (via `payload.query`) and `afterFind` exposes the
90
+ matched rows (via `payload.objects`). And unlike `afterSave` (whose return
91
+ value Parse Server ignores), **`afterFind` is result-rewriting**: whatever the
92
+ handler returns *replaces* the rows sent to the client, so it can filter or
93
+ redact results. It also adds a webhook round-trip to every matching query, so
94
+ register it deliberately.
95
+
96
+ One non-obvious detail the SDK handles for you: **Parse Server does not put the
97
+ class name anywhere in the find payload body** — the matched objects omit
98
+ `className` and there is no top-level one. The SDK derives the class from the
99
+ webhook URL path (`<endpoint>/<trigger>/<className>`) so your `afterFind` /
100
+ `beforeFind` block routes correctly and `payload.parse_class` resolves. (If you
101
+ build a `Payload` yourself in a test, pass the class as the second argument:
102
+ `Parse::Webhooks::Payload.new(body, "MyClass")`.)
103
+
104
+ Because the class is resolved from the route, declared `:vector` columns are
105
+ stripped from `afterFind` `payload.objects` by default, exactly as they are
106
+ from `object`/`original`/`update` on the other triggers (a
107
+ `vector_visibility :public` class keeps them). One consequence to keep in
108
+ mind: an `afterFind` handler that returns `payload.objects` to pass results
109
+ through passes the *vector-scrubbed* rows on to the client — which matches the
110
+ `as_json` default (an `owner_only` class never exposes vectors anyway). Return
111
+ your own array if you need different columns.
112
+
113
+ - **Auth triggers (`beforeLogin` / `afterLogin` / `afterLogout` /
114
+ `beforePasswordResetRequest`) and LiveQuery triggers (`beforeConnect` /
115
+ `beforeSubscribe` / `afterEvent`) are routed as first-class shapes** — they
116
+ are not object save/delete triggers, so **none of them run ActiveModel
117
+ `save` / `create` / `destroy` callbacks**, even the login/logout/reset ones
118
+ that carry a `_User` or `_Session`.
119
+
120
+ Identify them with the matching predicates — `before_login?`, `after_login?`,
121
+ `after_logout?`, `before_password_reset_request?`, `before_connect?`,
122
+ `before_subscribe?`, `after_event?` — or the category helpers `auth_trigger?`
123
+ / `live_query_trigger?`. Useful accessors by shape:
124
+
125
+ | Trigger | what the payload carries |
126
+ |---------|--------------------------|
127
+ | `beforeLogin` | the user being authenticated as **`payload.parse_object`** (a `_User`). `payload.user` is **`nil`** — auth isn't complete yet. |
128
+ | `afterLogin` | both `payload.parse_object` and `payload.user` (the now-authenticated user). |
129
+ | `afterLogout` | the session as `payload.parse_object` (a `_Session`). |
130
+ | `beforePasswordResetRequest` | the target user as `payload.parse_object`. |
131
+ | `beforeConnect` | connection-global: no object; the caller token (if any) in `payload.session_token`; counts in `payload.clients` / `payload.subscriptions`. |
132
+ | `beforeSubscribe` | shaped like `beforeFind` — `payload.query` / `payload.parse_query`; className comes from the route. Caller token in `payload.session_token`. |
133
+ | `afterEvent` | the event type in `payload.event` (`create` / `enter` / `update` / `leave` / `delete`), plus `payload.object` / `payload.original`. |
134
+
135
+ > The login footgun: during `beforeLogin` reach for `payload.parse_object`,
136
+ > **not** `payload.user` (which is `nil`). For connect/subscribe the live
137
+ > session token is at the top level of the payload, not nested under a user —
138
+ > the SDK captures it into `payload.session_token` (so `payload.user_client` /
139
+ > `payload.user_agent` work) and keeps it out of `as_json` and the request log.
140
+
141
+ **Response contract — what you return matters only for the `before*` ones.**
142
+ Parse Server **ignores the response body for all seven** of these triggers
143
+ (its webhook response handler resolves `{}` regardless). The *only* way a
144
+ handler affects the operation is by **rejecting** it, and only the `before*`
145
+ variants can be rejected (an `after*` trigger fires after the fact):
146
+
147
+ ```ruby
148
+ Parse::Webhooks.route(:before_login, "_User") do |payload|
149
+ error!("account suspended") if payload.parse_object.suspended? # denies login
150
+ # returning false also denies (mapped to the error response); anything else
151
+ # — including the user object — succeeds as a no-op
152
+ end
153
+
154
+ Parse::Webhooks.route(:after_event, "Post") do |payload|
155
+ AuditLog.record(payload.event, payload.parse_id) # observe-only; return value ignored
156
+ end
157
+ ```
158
+
159
+ Note the asymmetry with `before_save`: Parse Server treats a `{success:false}`
160
+ body as **allow** (only an `{error}` body rejects). So "return `false` to deny
161
+ login" only works because the SDK converts that `false` into an error response
162
+ for you. `error!(message)` is the explicit, message-carrying form.
163
+
164
+ **LiveQuery delivery caveat.** `beforeConnect` / `beforeSubscribe` /
165
+ `afterEvent` fire inside the LiveQuery server. They are delivered to an HTTP
166
+ webhook **only in a co-located, single-process LiveQuery setup**; with a
167
+ separate LiveQuery server they are in-process (`Parse.Cloud`) only.
168
+ `beforeConnect` in particular carries a live client handle that does not
169
+ serialize over HTTP, so it is effectively in-process-only. Register them when
170
+ you know your topology supports it.
171
+
172
+ ## Defining and registering webhooks
173
+
174
+ ```ruby
175
+ Parse::Webhooks.key = ENV.fetch("PARSE_WEBHOOK_KEY") # matches Parse Server's webhookKey
176
+
177
+ class Post < Parse::Object
178
+ property :title, :string
179
+
180
+ before_save :normalize # runs server-side once beforeSave is registered
181
+ after_create :index_for_search # runs inside the afterSave handler for new posts
182
+
183
+ webhook :before_save do # optional block, in addition to callbacks
184
+ parse_object # return the object (or `false` to halt the save)
185
+ end
186
+ end
187
+ ```
188
+
189
+ Register with Parse Server (once, at deploy — requires the master key).
190
+ `endpoint` is the public HTTPS URL where the Rack app is reachable:
191
+
192
+ ```ruby
193
+ Parse::Webhooks.register_functions!("https://hooks.example.com/webhooks")
194
+ Parse::Webhooks.register_triggers!("https://hooks.example.com/webhooks")
195
+ ```
196
+
197
+ Mount the Rack app (`config.ru`):
198
+
199
+ ```ruby
200
+ require_relative "app/webhooks"
201
+ run Parse::Webhooks
202
+ ```
203
+
204
+ See [`examples/webhook_server.rb`](../examples/webhook_server.rb) for a complete,
205
+ runnable setup.
206
+
207
+ ## Auditing trigger coverage
208
+
209
+ The wiring above has three independent moving parts, and a callback runs
210
+ server-side only when all three line up:
211
+
212
+ 1. the model's **ActiveModel callback** (`after_save :send_email`),
213
+ 2. a **local webhook route** so the router has a handler to run (the
214
+ `webhook :after_save` block, or `Parse::Webhooks.route(:after_save, "Post")`),
215
+ 3. the **server trigger** registered with Parse Server (`register_triggers!`),
216
+ so Parse Server actually POSTs to your app.
217
+
218
+ Declaring the callback alone does nothing for a non-Ruby client — the save
219
+ never touches your Ruby process. It is easy for these three to drift: a new
220
+ `after_save` callback with no block, a `webhook` block you never registered, or
221
+ a stale server trigger pointing at a class whose block was removed.
222
+
223
+ `Parse::Webhooks.trigger_audit` cross-references all three across every
224
+ registered class and reports the gaps. The server comparison reads the
225
+ master-key-only `hooks/triggers` endpoint, so it needs a master-key client;
226
+ pass `network: false` to audit callbacks against local routes only.
227
+
228
+ ```ruby
229
+ puts Parse::Webhooks.trigger_audit(pretty: true) # human-readable summary
230
+ report = Parse::Webhooks.trigger_audit # Hash report
231
+ Parse::Webhooks.trigger_audit(network: false) # local-only, no master key
232
+ ```
233
+
234
+ The audit emits four kinds of findings:
235
+
236
+ - **`callbacks_inert`** — a model has callbacks mapping to a trigger
237
+ (`after_save` / `after_create` → `afterSave`, etc.) but the local block and/or
238
+ the server trigger is missing, so they never fire for non-Ruby clients. The
239
+ `missing:` list says which piece to add. This is the headline gap.
240
+ - **`route_not_registered`** — a local `webhook :X` block exists but the trigger
241
+ isn't on the server, so Parse Server never calls it. Fix by running
242
+ `register_triggers!`.
243
+ - **`orphan_server_trigger`** — a server trigger is registered but no local block
244
+ handles it; every matching operation pays a webhook round-trip that does
245
+ nothing.
246
+ - **`local_only_callbacks`** — informational: `before_update` / `after_update`
247
+ and `before_validation` / `after_validation` callbacks have **no** Parse Server
248
+ trigger that can run them (the webhook router runs only the save and create
249
+ chains). They fire for Ruby-initiated saves but never for non-Ruby clients,
250
+ and no registration changes that.
251
+
252
+ Wire it into CI or a deploy check to fail fast on a coverage gap:
253
+
254
+ ```ruby
255
+ inert = Parse::Webhooks.trigger_audit[:summary][:findings][:callbacks_inert].to_i
256
+ abort "Webhook coverage gaps detected" if inert.positive?
257
+ ```
258
+
259
+ ## Returning a value from a handler
260
+
261
+ A handler block runs with `self` bound to the `Parse::Webhooks::Payload`, so
262
+ inside it you can call `parse_object`, `params`, `error!`, etc. directly. The
263
+ value the handler produces is what Parse Server receives: for `before_save`,
264
+ return the (possibly mutated) `parse_object` to allow the write, or `false` /
265
+ `error!` to reject it.
266
+
267
+ You can set that value either with an explicit `return` or by letting it be the
268
+ block's last expression — both work:
269
+
270
+ ```ruby
271
+ Parse::Webhooks.route :before_save, :Post do
272
+ post = parse_object
273
+
274
+ return post if post.title.present? # explicit early return
275
+ error! "title is required" # raise to reject the save
276
+ end
277
+
278
+ # Equivalent, using the last-expression value:
279
+ Parse::Webhooks.route :before_save, :Post do
280
+ post = parse_object
281
+ post.title.present? ? post : error!("title is required")
282
+ end
283
+ ```
284
+
285
+ The legacy proc idioms remain valid too — `next value` and `break value` both
286
+ set the result. `return`, like anywhere in Ruby, ends the handler immediately,
287
+ so nothing written after it in the same block runs. To run work *after* the
288
+ response, use [`after_response`](#deferring-work-until-after-the-response)
289
+ rather than writing code after the `return`.
290
+
291
+ ## Deferring work until after the response
292
+
293
+ `payload.after_response { … }` (alias `defer`) registers a block to run **after**
294
+ the webhook response has been sent to Parse Server — off the critical path of the
295
+ save or function the client is waiting on. The handler still returns its value
296
+ synchronously (that value is the response Parse Server acts on); the deferred
297
+ block runs afterward. Use it for follow-up work that should not add latency:
298
+ search indexing, cache warming, fan-out notifications.
299
+
300
+ ```ruby
301
+ Parse::Webhooks.route :after_save, :Post do
302
+ post = parse_object
303
+ after_response { SearchIndex.reindex(post.id) } # runs after the reply is sent
304
+ post
305
+ end
306
+ ```
307
+
308
+ How it runs:
309
+
310
+ - **Under Puma or Unicorn** the block is enqueued on `rack.after_reply` and runs
311
+ once the response is flushed to the socket, on the same worker thread — so it
312
+ adds nothing to the client's round-trip.
313
+ - **On a server without `rack.after_reply`** (e.g. WEBrick) it falls back to a
314
+ detached thread per request with deferred work — there is no pool or cap, so
315
+ under high request volume those threads can accumulate. Run the webhook app
316
+ under **Puma or Unicorn in production** (both provide `rack.after_reply`, which
317
+ runs the work on the existing worker thread with no extra thread spawned); the
318
+ thread fallback is best treated as a development-server convenience.
319
+ - Multiple `after_response` blocks run in registration order, and each is
320
+ isolated — one raising affects neither the response nor the others.
321
+ - `self` inside the block is the payload, so `parse_object`, `params`, etc. are
322
+ available (it closes over the handler's scope).
323
+
324
+ Things to know before relying on it:
325
+
326
+ - **Success path only.** Deferred blocks run only when the handler produced a
327
+ successful response. If a `before_save` rejects the write (`error!`, a raise,
328
+ or returning `false`), its registered `after_response` blocks do **not** run.
329
+ - **"After the response" is not "after the row commits."** The block runs after
330
+ the *response* is flushed. For `before_save` that is before Parse Server has
331
+ committed the write; even for `after_save` the SDK does not guarantee commit
332
+ ordering relative to the deferred block. Do not rely on the persisted row being
333
+ readable inside it.
334
+ - **In-process and best-effort.** The work runs in the web worker and does not
335
+ survive a restart, crash, or deploy. For work that *must* happen — payment
336
+ capture, irreversible side effects — hand it to a durable job queue
337
+ (Sidekiq / ActiveJob) instead; `after_response` is for latency-shedding, not
338
+ durability.
339
+ - **Mounted-app only.** Deferred blocks are drained by the `Parse::Webhooks` Rack
340
+ app. Invoking a handler directly (`Parse::Webhooks.run_function`, or calling
341
+ `call_route` in a unit test) does not run them — `after_response` is a no-op
342
+ there.
343
+ - **Capturing `user_client` / `user_agent` extends the token's lifetime.** A
344
+ deferred block closes over the payload, so referencing `payload.user_client` /
345
+ `payload.user_agent` (or `payload.session_token`) keeps the caller's live
346
+ session token in memory until the block finishes — beyond the synchronous
347
+ request. That is fine and expected when the deferred work needs to act as the
348
+ caller; just don't capture them when the work doesn't need the user's
349
+ authority (use a master-key client instead), so the token isn't pinned longer
350
+ than necessary.
351
+
352
+ ## Latency: webhooks are synchronous
353
+
354
+ Every registered webhook adds a **separate, synchronous HTTP round-trip** to the
355
+ client's operation. Parse Server **waits for the webhook to return before
356
+ proceeding** — and it waits even on `afterSave`, despite the afterSave return
357
+ value being a no-op.
358
+
359
+ This has direct design consequences for `afterSave` (and `afterDelete`):
360
+
361
+ - **Enqueue, don't execute.** Treat `after_save` as a place to hand work to a
362
+ background job, not to do long-running logic inline. Anything slow here is
363
+ added latency on every save, for every client. For in-process follow-up that
364
+ doesn't need a durable queue, [`after_response`](#deferring-work-until-after-the-response)
365
+ moves it off the client's round-trip; for anything that *must* happen, use a
366
+ real job queue.
367
+ - **Avoid saving other objects during an afterSave.** Each cascading save fires
368
+ its own webhooks, which can fire more — a latency cascade. If you must, do it
369
+ in a background job, not inline in the handler.
370
+
371
+ `beforeSave` is necessarily inline (it can mutate or reject the write), so keep
372
+ it lean and deterministic.
373
+
374
+ ## Server-side dedup: two distinct mechanisms
375
+
376
+ Two different "dedup" systems protect webhook handling. They solve different
377
+ problems — don't conflate them.
378
+
379
+ ### 1. Ruby-initiated dedup (keep logic local, prevent double-runs)
380
+
381
+ When a save is initiated by **this SDK with the master key**, Parse Stack tags
382
+ the request as trusted-Ruby-initiated (an `_RB_` request-id marker plus the
383
+ master key). It has already run the model's `before_save` / `after_save` /
384
+ `after_create` ActiveModel callbacks **locally**. The webhook therefore does
385
+ **not** re-run those callbacks — that would double-fire side effects (e.g. an
386
+ `after_save :send_email` would send two emails per save).
387
+
388
+ The intent is to keep trigger logic local when possible and run it exactly once.
389
+ Note that any logic in the **webhook block itself** still runs; only the
390
+ duplicate ActiveModel callback pass is skipped. A spoofed `_RB_` marker without
391
+ the master key does not get this treatment — the callbacks run in the webhook as
392
+ usual.
393
+
394
+ ### 2. Server-initiated replay / freshness protection (inbound)
395
+
396
+ This protects the webhook endpoint against **replayed inbound POSTs** —
397
+ `lib/parse/webhooks/replay_protection.rb`:
398
+
399
+ - **Always-on body + request-id dedup.** A bounded LRU records a digest of each
400
+ `(request_id, body)`; a duplicate seen within `replay_window_seconds` is
401
+ rejected with `"Webhook replay detected."`. No cooperation from Parse Server is
402
+ required; this stops in-window replays.
403
+ - **Opt-in HMAC freshness verification.** Set a `signing_secret` and the receiver
404
+ verifies two headers:
405
+ - `X-Parse-Webhook-Timestamp` — Unix epoch seconds; requests outside
406
+ `signing_max_skew_seconds` (default 300) are rejected as stale.
407
+ - `X-Parse-Webhook-Signature` — hex HMAC-SHA256 of `"#{timestamp}.#{body}"`
408
+ keyed with the signing secret.
409
+
410
+ ```ruby
411
+ Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
412
+ Parse::Webhooks::ReplayProtection.replay_window_seconds = 120
413
+ Parse::Webhooks::ReplayProtection.signing_max_skew_seconds = 300
414
+ ```
415
+
416
+ This is **inbound** protection and is unrelated to request **idempotency**
417
+ (`X-Parse-Request-Id`), which dedups the SDK's own **outbound** retries on the
418
+ Parse Server side. Different direction, different mechanism.
@@ -0,0 +1,46 @@
1
+ # Examples
2
+
3
+ Runnable scripts that exercise `parse-stack-next` against a live Parse Server.
4
+ Each file is self-contained and reads its configuration from environment
5
+ variables. Start here:
6
+
7
+ | Script | Demonstrates | Needs |
8
+ |---|---|---|
9
+ | [`basic_server.rb`](basic_server.rb) | Privileged (master-key) setup: define models, push schema with `auto_upgrade!`, full CRUD + queries with a `belongs_to`. | app id, REST key, **master key** |
10
+ | [`basic_client.rb`](basic_client.rb) | Unprivileged client (no master key): login/signup, `with_session`, and a row-level **ACL enforcement** demo (the owner reads a record; an anonymous caller gets `nil`). | app id, REST key |
11
+ | [`live_query_listener.rb`](live_query_listener.rb) | Interactive LiveQuery console: subscribes scoped to a user's session token and prints create / update / delete events until Ctrl-C — you only "hear" what that user may read. | app id, REST key, LiveQuery URL |
12
+ | [`rag_chatbot.rb`](rag_chatbot.rb) | Retrieval-augmented generation: managed `embed`, `agent_searchable`, `semantic_search` via `Parse::Agent`, plus an OpenAI/Anthropic generation add-in. | app id, REST key, master key, `OPENAI_API_KEY` (+ Atlas) |
13
+ | [`transaction_example.rb`](transaction_example.rb) | Atomic multi-object operations via `Parse::Object.transaction`. | app id, REST key |
14
+
15
+ ## Common setup
16
+
17
+ All scripts read a Parse connection from the environment:
18
+
19
+ ```bash
20
+ export PARSE_SERVER_URL=http://localhost:1337/parse
21
+ export PARSE_APP_ID=your-app-id
22
+ export PARSE_REST_KEY=your-rest-api-key
23
+ export PARSE_MASTER_KEY=your-master-key # server-side scripts only
24
+ ```
25
+
26
+ Then run any script with the gem on the load path:
27
+
28
+ ```bash
29
+ ruby -Ilib examples/basic_server.rb
30
+ ```
31
+
32
+ ## Suggested order
33
+
34
+ 1. **`basic_server.rb`** — defines and provisions the `Artist`, `Song`, and
35
+ `Post` classes the other scripts use. Run it first.
36
+ 2. **`basic_client.rb`** — see how the same SDK behaves without the master key,
37
+ and watch Parse Server enforce a row-level ACL.
38
+ 3. **`live_query_listener.rb`** — leave it running, then create/update/destroy
39
+ `Post`s from another terminal (or the dashboard) and watch them stream in.
40
+ 4. **`rag_chatbot.rb`** — requires an Atlas-backed server and an embedding key;
41
+ see [`../docs/atlas_vector_search_guide.md`](../docs/atlas_vector_search_guide.md)
42
+ for the vector-search setup.
43
+
44
+ > Each script's header comment lists the exact environment variables and any
45
+ > prerequisites (e.g. `basic_client.rb` needs the `Post` class to already
46
+ > exist, which `basic_server.rb` provisions).