parse-stack-next 5.2.0 → 5.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25238eea832d32b8c536522ec1c43d087b3db8e996b13d39430c2edfd74481ee
4
- data.tar.gz: 806f28241cfbfe7e77fa230bd2e6b2b4aa071b89b7dda828f52d0b5182a7122c
3
+ metadata.gz: 90a966c230e0a0e2e6fd814170726e3af713dae06c0341a00cd7581eecbe53ee
4
+ data.tar.gz: b171ed432c7ea7806ec07653b47122bd6102a16046c980d915fe45f98ffbca32
5
5
  SHA512:
6
- metadata.gz: b85adcba96ac6023989bf42e8676eee9bb22b4ef0cb02073cc7ed0b79078c3edd88a84ff6f0b6c6f15eda214ba6a9c3165d35c7c250ef1ecfcd0471f58c3929c
7
- data.tar.gz: 9646a8b0f4bcd679a373d8a3ba189f0f160a7227280690b2ca165987b535f0c0ac6972dcf04d053ba441b732f80462698d7e1e08365c4419368aeea4481117c6
6
+ metadata.gz: 510f3f2deb860cedc73f11455fb5d4789c327923abebad4e26b223ae9c385374ffa23bc9564561e7d4dddb31b0c1a015b7e7222dacd7c0b18bdb01199580d7a0
7
+ data.tar.gz: cd0fb4c706b3c33f12059c7431cf75abd792cbd5ba8b193dd43256aaa70c8fcdd3f86e10b3ad8ffb25c8e9ccc3f985a61bcaa8b49d24badf6f8358cec2b16f3d
data/.bundle/config CHANGED
@@ -1,4 +1,5 @@
1
1
  ---
2
2
  BUNDLE_FORCE_RUBY_PLATFORM: "true"
3
+ BUNDLE_FROZEN: "false"
3
4
  BUNDLE_PATH: "/home/runner/work/parse-stack-next/parse-stack-next/vendor/bundle"
4
5
  BUNDLE_DEPLOYMENT: "true"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,245 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.3.0
4
+
5
+ #### Run webhook handlers as the calling user
6
+
7
+ Parse Server includes the caller's live session token (`user.sessionToken`) in
8
+ every trigger webhook fired by a logged-in user. A handler can now opt in to
9
+ acting on the server *as that user* — with full ACL, CLP, and `protectedFields`
10
+ enforcement — instead of reaching for the application master key.
11
+
12
+ - **NEW**: `Parse::Webhooks::Payload#session_token` exposes the caller's session
13
+ token, captured from the incoming payload. It is `nil` for a master-key
14
+ request (which carries no user). The token is captured before the payload's
15
+ credential scrubbing runs, so it is still removed from `payload.user` /
16
+ `payload.object` and never appears in `payload.as_json` or the request log.
17
+ - **NEW**: `Parse::Webhooks::Payload#user_agent` returns a non-master
18
+ `Parse::Agent` bound to the caller's session token, so it runs in client mode
19
+ and every query is authorized as the user. Returns `nil` when there is no
20
+ token.
21
+ - **NEW**: `Parse::Webhooks::Payload#user_client` returns a non-master
22
+ `Parse::Client` that binds the caller's session token, so even raw REST calls
23
+ through it are authorized as the user with no further ceremony. Returns `nil`
24
+ when there is no token.
25
+ - **NEW**: `Parse::Client.new` accepts a `session_token:` option that binds a
26
+ token to the client. It is applied as the lowest-priority auth fallback on
27
+ every request (explicit per-call `session_token:` and `Parse.with_session`
28
+ still take precedence, and an explicit `use_master_key: true` skips it).
29
+ Pair it with `master_key: nil` to build a user-scoped client.
30
+ - **NEW**: `Parse::Client#become(session_token)` returns a new non-master client
31
+ that mirrors the receiver's connection settings (`server_url`/`app_id`/
32
+ `api_key`) but binds the given token — the primitive behind `payload.user_client`
33
+ and `Parse::User#session_client`. `Parse::Client#anonymous` returns the same
34
+ shape with no token (unauthenticated REST) to drop a bound identity.
35
+ - **NEW**: `Parse::User#session_client` returns a non-master `Parse::Client`
36
+ bound to a logged-in user's session token — the client-side counterpart of
37
+ `payload.user_client` (e.g. `Parse::User.login(u, p).session_client`).
38
+ - **NEW**: `Parse::Client#with_session { ... }` runs a block with the client's
39
+ bound session token active as the ambient session, so REST-routed operations
40
+ (`find`/`get`/`count`/`save`) inside it are authorized as the user without
41
+ passing `session_token:` per call (the client-receiver flavor of
42
+ `Parse.with_session` / `Parse::User#with_session`). Mongo-direct queries
43
+ (`results_direct`, `aggregate`, Atlas search) are unaffected and still require
44
+ explicit per-query scoping.
45
+ - **NEW**: `rake client:console` opens an interactive console whose default
46
+ client is a non-master client bound to a user (session token, login, or
47
+ anonymous), so every query in the session runs with that user's ACL / CLP /
48
+ `protectedFields` rather than the master key.
49
+ - **FIXED**: `Parse::Client#inspect` and `Parse::Webhooks::Payload#inspect` no
50
+ longer print the master key or session token (and, for the payload, the
51
+ pre-scrub raw credentials) in cleartext — they are redacted, so an error
52
+ reporter or stray `inspect` cannot leak them.
53
+ - **FIXED**: a whitespace-only `Parse::Client` `session_token:` is now treated
54
+ as no token, so it can never silently fall through to the master key.
55
+
56
+ #### Pluralized class-name aliases
57
+
58
+ Referencing the plural form of a model constant now resolves to that class, so
59
+ the plural reads naturally as a query entry point — `Posts.where(...).count`
60
+ works for a class `Post`.
61
+
62
+ - **NEW**: The plural alias is created lazily on first reference and is the
63
+ *same class object* as the singular (`Posts.equal?(Post)` is `true`), so every
64
+ class method works through it (`query`/`where`, `count`, `find`, `all`, and any
65
+ `scope`) and `Posts.parse_class` still returns `"Post"`. Because it is the same
66
+ class, it adds no `Parse::Object.descendants` entry and never registers a
67
+ separate Parse schema class.
68
+ - **NEW**: `pluralized_alias!` class macro for explicit opt-in — use it for a
69
+ custom plural, a class whose name ends in `s`, or a namespaced model (the alias
70
+ is defined on the enclosing module). It ignores the global flag and raises
71
+ `ArgumentError` rather than clobbering an existing unrelated constant.
72
+ - **NEW**: `Parse.pluralized_aliases` configuration (default `true`; opt out with
73
+ `Parse.pluralized_aliases = false` or `PARSE_PLURALIZED_ALIASES=false`).
74
+ - **CHANGED**: The automatic path is conservative — a class whose name already
75
+ ends in `s` is skipped, and a plural that does not singularize to a known
76
+ `Parse::Object` subclass falls through to a normal `NameError`.
77
+
78
+ #### Pointer associations declared with `property … as:` — BREAKING: now serialize as Pointers
79
+
80
+ Declaring a pointer with `property` instead of `belongs_to` used to fail
81
+ silently: the `as:` option was dropped and the field became a plain `:string`
82
+ column, so an assigned object was stored as a String and never serialized as a
83
+ Parse pointer. `property … as:` now resolves to the same pointer association as
84
+ `belongs_to`.
85
+
86
+ - **BREAKING**: a field declared with `property … as:` now serializes as a
87
+ Pointer (`{__type: "Pointer", …}`) instead of a String. If a deployed app
88
+ previously used `property … as:` and saved records, Parse Server pinned that
89
+ column as `String` on first write, and a Pointer write against it is rejected
90
+ with error 111 (schema type mismatch). Drop or migrate that column to a
91
+ Pointer type before deploying. Apps that never used `property … as:` are
92
+ unaffected — the option was a silent no-op, so no working pointer behavior
93
+ depended on it.
94
+ - **FIXED**: `property <name>, as: <class>` and `property <name>, :pointer` now
95
+ declare a pointer association identical to the equivalent `belongs_to` — same
96
+ `:pointer` field type, remote column, target-class reference, getter/setter,
97
+ dirty tracking, and `{__type: "Pointer", …}` wire form. Previously `as:` was
98
+ ignored and the field defaulted to `:string`, with no warning.
99
+ - **NEW**: `belongs_to` (and the delegating `property … as:`) raise an
100
+ `ArgumentError` at declaration time when `as:` names a scalar data type such
101
+ as `as: :string` — a scalar cannot be a pointer target. The message points to
102
+ the intended `property <name>, :<type>` form. The guard fires only on an
103
+ explicit `as:`, so existing `belongs_to :field` declarations are unaffected.
104
+ - **NEW**: `Parse.validate_associations!` verifies that every `belongs_to` /
105
+ `property … as:` pointer target and every `has_many` target — relation-,
106
+ array-, and query-backed — resolves to a known Parse class, reporting any
107
+ unresolved target as a `Class#field -> "Target"` offender. The query- and
108
+ array-backed `has_many` targets are the bucket where an `as:` typo otherwise
109
+ stays latent until the association is first traversed. Forward references are
110
+ legal at
111
+ declaration time and indistinguishable from typos there, so this cross-class
112
+ check is meant to run once after all models load (boot, CI, or a rake task).
113
+ - **IMPROVED**: `belongs_to` now stores `_description:` and `_enum:` agent
114
+ metadata, which previously only `property` retained. A documented pointer
115
+ association keeps its semantic description, including when declared through
116
+ `property … as:`.
117
+
118
+ #### afterSave create reports changed fields; file equality is force_ssl-consistent
119
+
120
+ A trigger handler that keys off dirty tracking (`*_changed?` / `changes`) now
121
+ sees every field an object was created with on an `afterSave` create, symmetric
122
+ with the existing `afterSave` update behavior, and two `Parse::File` values that
123
+ point at the same location compare equal regardless of `force_ssl`.
124
+
125
+ - **NEW**: On an `afterSave` trigger for a newly created object (no prior
126
+ persisted state), the built `Parse::Object` now marks every populated data
127
+ property as changed, so a handler can use `*_changed?` / `changes` / `changed`
128
+ uniformly across create and update. Previously the create object
129
+ was pristine, so `*_changed?` returned `false` for every field and a handler
130
+ that builds a payload from changed fields would see nothing on creates. A
131
+ property whose create value equals its declared `default:` (e.g.
132
+ `status: "draft"`, `count: 0`, `archived: false`) is correctly reported as
133
+ changed. System fields (`createdAt` / `updatedAt` / `ACL` / `objectId`) stay
134
+ clean — they are not reported as changed — and object readability, `new?`, and
135
+ `existed?` are unchanged. Credential and row-permission fields remain filtered
136
+ from the built object. The `afterSave` update path is unchanged.
137
+ - **CHANGED**: As a result of the above, model `after_save` / `after_create`
138
+ callbacks fired through the webhook dispatcher now observe an `afterSave`-create
139
+ object whose data fields are dirty (rather than pristine). A handler that
140
+ branched on dirty state for a created object should review that expectation;
141
+ the values themselves are unchanged.
142
+ - **FIXED**: `Parse::File#==` now compares both files through the canonical
143
+ `url` reader rather than the raw stored URL on one side, so the comparison is
144
+ symmetric (`a == b` matches `b == a`) and consistent when
145
+ `Parse::File.force_ssl` coerces a stored `http://` URL to `https://`. Two
146
+ files at the same location previously read as unequal under `force_ssl`, which
147
+ could spuriously mark a file property as changed during dirty tracking.
148
+ Signed-URL query parameters are still stripped before comparison, so a
149
+ re-signed URL for the same object compares equal while a different underlying
150
+ location does not.
151
+ - **NEW**: `Parse::File#content_signature` is the overridable seam that `==`
152
+ uses to decide whether two files refer to the same underlying file. It returns
153
+ the canonical `url` today (Parse Server's S3 files adapter exposes no content
154
+ digest); a files-adapter shim or `Parse::File` subclass can override it to key
155
+ equality off a content hash (S3 ETag / sha256) in the future without touching
156
+ dirty tracking.
157
+
158
+ ### 5.2.1
159
+
160
+ #### Webhook trigger handlers now receive the full Parse object
161
+
162
+ Webhook trigger payloads (`beforeSave`/`afterSave`/`beforeDelete`/`afterDelete`)
163
+ are delivered by Parse Server and authenticated by the webhook key, so they are
164
+ trusted, server-authoritative state. Previously the payload was filtered through
165
+ the wide mass-assignment denylist, which stripped server-issued
166
+ `createdAt`/`updatedAt` (and other non-credential fields) before the handler
167
+ could see them. That broke `Parse::Object#existed?` and `#new?` inside
168
+ `afterSave` handlers — `existed?` always returned `false` and `new?` always
169
+ returned `true`, regardless of whether the object was created or updated — and
170
+ hid the object's timestamps and ACL from handler code.
171
+
172
+ - **FIXED**: `afterSave`/`beforeSave` handlers now receive the full object as
173
+ Parse Server sends it (`createdAt`, `updatedAt`, `ACL`, internal fields).
174
+ `Parse::Object#existed?` and `#new?` are now reliable inside `afterSave`
175
+ handlers. (Genuine credentials — session tokens and password hashes — are
176
+ still stripped, and `Parse::User` continues to protect `authData` on
177
+ `payload.user`.)
178
+ - **NEW**: `afterSave` handlers on an updated object now carry dirty tracking
179
+ relative to the prior state, so `title_changed?`, `changed`, and `changes`
180
+ work inside `afterSave` the same way they already did inside `beforeSave`.
181
+ - **CHANGED**: Inbound webhook trigger payloads are now scrubbed of genuine
182
+ credential material only (`sessionToken`, `_hashed_password`,
183
+ `_password_history`) rather than the full mass-assignment denylist. Protection
184
+ against persisting forged privileged fields remains on the write path: a save
185
+ emits only declared, dirty-tracked properties, and an after-trigger response
186
+ is `true`/`false`, so forged `_rperm`/`_wperm`/`authData` cannot be persisted
187
+ through a handler. This applies only to the inbound webhook trigger payload;
188
+ client login/signup responses are unaffected and still return session tokens.
189
+ - **CHANGED**: In an `afterSave` handler, `new?` now correctly returns `false`
190
+ (the object is already persisted) where the previous timestamp-stripping bug
191
+ made it return `true`. Use `existed?` to distinguish create from update inside
192
+ `afterSave` (`existed? == false` for a create, `true` for an update); `new?`
193
+ is intended for `beforeSave`.
194
+ - **CHANGED**: Dirty-gated `after_save` side effects now fire on client/REST-
195
+ initiated saves where they previously silently no-op'd. With timestamps and
196
+ dirty tracking restored, a callback such as `after_save { notify if
197
+ title_changed? }` will now activate for objects created or updated via REST /
198
+ JS cloud code, not only for Ruby-model saves.
199
+
200
+ ```ruby
201
+ Parse::Webhooks.route :after_save, "Post" do
202
+ post = parse_object
203
+
204
+ if post.existed? # now reliable: false on create, true on update
205
+ NotificationService.changed(post) if post.title_changed?
206
+ else
207
+ post.create_default_associations!
208
+ end
209
+ true
210
+ end
211
+ ```
212
+
213
+ #### Lifecycle callbacks run in ActiveModel order for client-initiated saves
214
+
215
+ Parse Server exposes no separate `beforeCreate`/`afterCreate` triggers — only
216
+ `beforeSave` and `afterSave`. The webhook layer now runs the model lifecycle
217
+ callbacks for a client-initiated create in the canonical ActiveModel order:
218
+ `before_save` → `before_create` (in the `beforeSave` webhook) then
219
+ `after_create` → `after_save` (in the `afterSave` webhook).
220
+
221
+ - **FIXED**: `before_create` callbacks now run for client/REST/JS/Auth0-created
222
+ objects. The `beforeSave` webhook runs `before_create` after `before_save` for
223
+ new objects (an object with no `original`); previously `before_create` never
224
+ fired for non-Ruby creates, so create-time setup written as `before_create`
225
+ was silently skipped.
226
+ - **FIXED**: `after_save` no longer double-fires on client-initiated saves. The
227
+ `beforeSave` webhook entry point previously ran the full save callback chain,
228
+ firing `after_save` during `beforeSave` in addition to the `afterSave`
229
+ webhook. It now runs the before phase only.
230
+ - **NEW**: `Parse::Object#run_before_save_callbacks` and
231
+ `#run_before_create_callbacks` — the before-phase counterparts to the existing
232
+ `run_after_save_callbacks` / `run_after_create_callbacks`.
233
+ - **CHANGED**: `Parse::Object#prepare_save!` is retained as a back-compat alias
234
+ for `run_before_save_callbacks` and now runs the before phase only (it no
235
+ longer also fires `after_save`). The before-phase runners honor `:if`/`:unless`
236
+ callback conditions and the callback terminator.
237
+ - **NOTE**: the webhook layer runs `before_save`/`before_create` and
238
+ `after_create`/`after_save`, but not `before_update`/`after_update` — those
239
+ `:update`-specific callbacks fire only on Ruby-model saves, not for
240
+ client-initiated (REST/JS/Auth0) saves. Use `before_save`/`after_save` (which
241
+ run for every save) and branch on `existed?` if you need update-only logic.
242
+
3
243
  ### 5.2.0
4
244
 
5
245
  #### Retrieval layer — `Parse::Retrieval` (`Parse::RAG`)
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- parse-stack-next (5.2.0)
4
+ parse-stack-next (5.3.0)
5
5
  activemodel (>= 6.1, < 9)
6
6
  activesupport (>= 6.1, < 9)
7
7
  connection_pool (>= 2.2, < 4)
data/README.md CHANGED
@@ -4,8 +4,15 @@
4
4
 
5
5
  A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [parse-stack-next](https://github.com/neurosynq/parse-stack-next) is a Ruby client SDK, REST client, and Active Model ORM for [Parse Server](http://parseplatform.org/), combining a low-level API client, a query engine, an object-relational mapper (ORM), and a Cloud Code Webhooks rack application in a single gem.
6
6
 
7
+ ### What's new in 5.3
8
+
9
+ - **5.3.0 — Run webhook handlers (and clients) as the calling user** — Parse Server embeds the caller's live session token in every trigger webhook fired by a logged-in user. A handler can now opt in to acting on the server *as that user* — full ACL/CLP/`protectedFields` enforcement, no master key. `payload.session_token` exposes the captured token (`nil` for master-key requests; still scrubbed from `payload.user`/`payload.object`/`as_json`/logs); `payload.user_agent` returns a client-mode `Parse::Agent`, and `payload.user_client` a non-master `Parse::Client` with the token **bound** so even raw REST calls authorize as the user. The same user-scoped client is available client-side via `Parse::User#session_client` and the `Parse::Client#become(token)` primitive, with `Parse::Client#with_session { … }` for block scoping. Backed by a new `Parse::Client.new(session_token:)` option. See [Acting as the calling user](#acting-as-the-calling-user)
10
+ - **5.3.0 — Pluralized class-name aliases** — referencing the plural form of a model constant now resolves to that class, so `Posts.where(:author.eq => user).count` works for a class `Post`. The alias is created lazily on first reference and is the *same class object*, so every class method (`query`/`where`, `count`, `find`, `all`, scopes) works through it and `Posts.parse_class` still returns `"Post"`. Because it is the same class it adds no `Parse::Object.descendants` entry and never registers a separate Parse schema class. Classes whose name already ends in `s` are skipped by the automatic path; non-Parse plurals and typos fall through to a normal `NameError`. On by default — opt out with `Parse.pluralized_aliases = false` (or `PARSE_PLURALIZED_ALIASES=false`). For a custom plural, an `s`-ending class, or a namespaced model, call `pluralized_alias!` in the class body. See [Pluralized class-name aliases](#pluralized-class-name-aliases)
11
+ - **5.3.0 — afterSave create reports changed fields; force_ssl-consistent file equality** — a trigger handler that keys off dirty tracking now sees every field on an `afterSave` *create*, symmetric with `afterSave` updates: the built object marks each populated data property changed (from `nil`) while `createdAt`/`updatedAt`/`ACL`/`objectId` stay clean and object readability, `new?`, and `existed?` are unchanged — so a handler that builds a payload from `*_changed?` / `changes` works uniformly across create and update. Separately, `Parse::File#==` now compares both files through the canonical `url` reader, so two files at the same location compare equal regardless of `Parse::File.force_ssl` (and `a == b` matches `b == a`), and a re-signed URL for the same object no longer reads as a change. See [Cloud Code Triggers](#cloud-code-triggers)
12
+
7
13
  ### What's new in 5.2
8
14
 
15
+ - **5.2.1 — Webhook triggers receive the full Parse object** — trigger handlers (`beforeSave`/`afterSave`/…) now get the complete server object (`createdAt`/`updatedAt`, `ACL`, internal fields); only live credentials (session tokens, password hashes) are stripped. `Parse::Object#existed?` / `#new?` are reliable in `afterSave`, `afterSave` updates carry dirty tracking, and the model lifecycle runs in ActiveModel order — `before_save → before_create` then `after_create → after_save` — so `before_create` now fires for REST/JS/Auth0 creates (and `after_save` no longer double-fires). See [Cloud Code Triggers](#cloud-code-triggers)
9
16
  - **Retrieval layer — `Parse::Retrieval` (`Parse::RAG`)** — `Parse::Retrieval.retrieve(query:, klass:, k:, filter:, tenant_scope:, …)` embeds a natural-language query, runs Atlas `$vectorSearch` through the existing ACL-enforcing `find_similar`, and splits each retrieved document's text field into scored `Parse::Retrieval::Chunk`s. Chunking is presentation-only (embedding stays one-vector-per-record), via `Parse::Retrieval::Chunker::FixedSizeOverlap(size:, overlap:, by:, max_chunks_per_document:)` (subclass `Chunker::Base` for custom strategies). ACL is mongo-direct (no REST two-stage); tenant scope folds into the Atlas pre-filter
10
17
  - **`semantic_search` agent tool + `agent_searchable`** — declare `agent_searchable field:, filter_fields:` on a model to expose it to the readonly, client-safe `semantic_search` tool. The handler enforces the full agent envelope: searchable-class allowlist, recursive underscore-key refusal + filter-field allowlist on input, `field_allowlist` projection plus tenant-scope re-assertion on output, and score quantization in non-admin contexts
11
18
  - **MCP elicitation — human-in-the-loop approval** — opt in with `Parse::Agent.require_approval_for = [:write, :admin]` to require spec-native `elicitation/create` approval before destructive tool calls. A pluggable `agent.approval_gate` (reachable on the non-MCP path too) shows the dry-run diff and blocks on the client's reply; `call_method` resolves the *effective* tier from the target `agent_method`. Fails closed (no capability / no listening stream / non-streaming transport / timeout → refuse); replies are session-bound
@@ -295,6 +302,7 @@ The 1.x line is the original [`modernistik/parse-stack`](https://github.com/mode
295
302
  - [Linking and Unlinking](#linking-and-unlinking)
296
303
  - [Request Password Reset](#request-password-reset)
297
304
  - [Modeling and Subclassing](#modeling-and-subclassing)
305
+ - [Pluralized class-name aliases](#pluralized-class-name-aliases)
298
306
  - [Defining Properties](#defining-properties)
299
307
  - [Accessor Aliasing](#accessor-aliasing)
300
308
  - [Property Options](#property-options)
@@ -397,6 +405,7 @@ The 1.x line is the original [`modernistik/parse-stack`](https://github.com/mode
397
405
  - [Cloud Code Webhooks](#cloud-code-webhooks)
398
406
  - [Cloud Code Functions](#cloud-code-functions)
399
407
  - [Cloud Code Triggers](#cloud-code-triggers)
408
+ - [Trigger object state](#trigger-object-state)
400
409
  - [Mounting Webhooks Application](#mounting-webhooks-application)
401
410
  - [Register Webhooks](#register-webhooks)
402
411
  - [Parse REST API Client](#parse-rest-api-client)
@@ -1620,6 +1629,61 @@ class Commentary < Parse::Object
1620
1629
  end
1621
1630
  ```
1622
1631
 
1632
+ ### Pluralized class-name aliases
1633
+
1634
+ As a convenience, the plural form of a model constant resolves to that class, so the plural reads naturally at a query call site:
1635
+
1636
+ ```ruby
1637
+ class Post < Parse::Object
1638
+ property :title, :string
1639
+ belongs_to :author, as: :user
1640
+ end
1641
+
1642
+ # `Posts` resolves to `Post` on first reference:
1643
+ Posts.where(:author.eq => current_user).count
1644
+ Posts.query(:title.exists => true).results
1645
+ Posts.find("abc123")
1646
+ ```
1647
+
1648
+ The alias is created lazily (via `const_missing`) the first time the plural constant is referenced, and it is the **same class object** as the singular — `Posts.equal?(Post)` is `true`. As a result:
1649
+
1650
+ - Every class method works through it for free: `query`/`where`, `count`, `find`, `all`, and any `scope` you define.
1651
+ - `Posts.parse_class` still returns `"Post"`. The alias is purely a Ruby-constant convenience.
1652
+ - It adds no `Parse::Object.descendants` entry and **never registers a separate Parse schema class** — schema introspection and `Parse.auto_upgrade!` see exactly one class.
1653
+
1654
+ The automatic path is deliberately conservative:
1655
+
1656
+ - A class whose name already ends in `s` (for example `Status`, `Series`) is **skipped**, since its plural is ambiguous.
1657
+ - A plural that does not singularize to a known `Parse::Object` subclass (a typo, or `Strings` → `String`) falls through to a normal `NameError` — the SDK does not change Ruby's behavior for non-model constants.
1658
+
1659
+ The feature is enabled by default. Opt out globally:
1660
+
1661
+ ```ruby
1662
+ Parse.pluralized_aliases = false # programmatic
1663
+ # or
1664
+ # PARSE_PLURALIZED_ALIASES=false # environment
1665
+ ```
1666
+
1667
+ For a custom plural, a class whose name ends in `s` that you *do* want aliased, or a namespaced model (where the alias should live on the enclosing module rather than at the top level), declare it explicitly with the `pluralized_alias!` macro. The explicit macro ignores the global flag and the `s`-ending guard:
1668
+
1669
+ ```ruby
1670
+ class Status < Parse::Object
1671
+ pluralized_alias! :Statuses # defines ::Statuses => Status
1672
+ end
1673
+
1674
+ module Blog
1675
+ class Article < Parse::Object
1676
+ pluralized_alias! # defines Blog::Articles => Blog::Article
1677
+ end
1678
+ end
1679
+ ```
1680
+
1681
+ If the target constant already exists and is not the class itself, `pluralized_alias!` raises `ArgumentError` rather than clobbering it (a code reloader swapping the class object is detected and the alias is re-pointed instead).
1682
+
1683
+ The automatic path and the macro differ in *where* the alias constant is installed. The macro always anchors it on the class's own parent — top level for `Post`, the enclosing module for a namespaced model. The automatic path installs it on the module where the plural was first referenced (Ruby's `const_missing` fires on the referencing lexical scope), so a bare `Posts` written inside `module Blog` defines `Blog::Posts`. Both resolve to the same class; if you want a single, predictable top-level constant, prefer the macro.
1684
+
1685
+ > **Note on reloading:** under a code reloader (for example Zeitwerk in Rails development), a model class is swapped for a fresh object on reload, but the alias constant the SDK created is not tracked by the reloader and is therefore not removed. The `pluralized_alias!` macro detects this on the next run of the class body and re-points the alias to the current class. An *automatically*-created alias, however, stays bound to the previous class object until the process restarts (`const_missing` cannot re-fire for a constant that is still defined). If you depend on the plural during development and want fully deterministic reload behavior, declare it explicitly with `pluralized_alias!`, or disable the feature with `Parse.pluralized_aliases = false`.
1686
+
1623
1687
  ### Defining Properties
1624
1688
  Properties are considered a literal-type of association. This means that a defined local property maps directly to a column name for that remote Parse class which contain the value. **All properties are implicitly formatted to map to a lower-first camelcase version in Parse (remote).** Therefore a local property defined as `like_count`, would be mapped to the remote column of `likeCount` automatically. The only special behavior to this rule is the `:id` property which maps to `objectId` in Parse. This implicit conversion mapping is the default behavior, but can be changed on a per-property basis. All Parse data types are supported and all Parse::Object subclasses already provide definitions for `:id` (objectId), `:created_at` (createdAt), `:updated_at` (updatedAt) and `:acl` (ACL) properties.
1625
1689
 
@@ -4825,7 +4889,9 @@ end
4825
4889
  ```
4826
4890
 
4827
4891
  ### Cloud Code Triggers
4828
- You can register webhooks to handle the different object triggers: `:before_save`, `:after_save`, `:before_delete` and `:after_delete`. The `payload` object, which is an instance of `Parse::Webhooks::Payload`, contains several properties that represent the payload. One of the most important ones is `parse_object`, which will provide you with the instance of your specific Parse object. In `:before_save` triggers, this object already contains dirty tracking information of what has been changed.
4892
+ You can register webhooks to handle the different object triggers: `:before_save`, `:after_save`, `:before_delete` and `:after_delete`. The `payload` object, which is an instance of `Parse::Webhooks::Payload`, contains several properties that represent the payload. One of the most important ones is `parse_object`, which will provide you with the instance of your specific Parse object.
4893
+
4894
+ The `parse_object` handed to your handler is the **full object as Parse Server sent it** — `createdAt`/`updatedAt`, `ACL`, and internal fields all survive (only live credentials — session tokens and password hashes — are stripped; `Parse::User` additionally protects `authData` on `payload.user`). Both `:before_save` and `:after_save` objects carry **dirty tracking** of what changed (`name_changed?`, `changes`), and `Parse::Object#existed?` / `#new?` are reliable inside `:after_save`. See [Trigger object state](#trigger-object-state) below.
4829
4895
 
4830
4896
  ```ruby
4831
4897
  # recommended way
@@ -4862,6 +4928,60 @@ For any `after_*` hook, return values are not needed since Parse does not utiliz
4862
4928
  > for saves from other clients (JS / iOS / REST), the webhook runs them, since
4863
4929
  > the SDK never had the chance.
4864
4930
 
4931
+ #### Trigger object state
4932
+
4933
+ Because the trigger payload is server-authoritative, the `parse_object` your
4934
+ handler receives is the complete object, and the usual `Parse::Object`
4935
+ introspection works inside the trigger:
4936
+
4937
+ | What you want to know | In `:before_save` | In `:after_save` |
4938
+ |---|---|---|
4939
+ | Is this a create or an update? | `parse_object.new?` (`true` = create) | `parse_object.existed?` (`false` = create) or `payload.original.nil?` |
4940
+ | What changed? | `name_changed?`, `changes`, `changed` | `name_changed?`, `changes`, `changed` (relative to the prior state) |
4941
+ | Server timestamps | not yet assigned (`new?` create) | `created_at` / `updated_at` populated |
4942
+ | The prior stored values | `payload.original_parse_object` | `payload.original_parse_object` |
4943
+
4944
+ Use `new?` in `:before_save` and `existed?` in `:after_save`. In `:after_save`
4945
+ the object is already persisted, so `new?` is `false` for both creates and
4946
+ updates — `existed?` (`created_at != updated_at`) is the create/update signal,
4947
+ equivalently `payload.original.nil?`.
4948
+
4949
+ ```ruby
4950
+ Parse::Webhooks.route :after_save, :Post do
4951
+ post = parse_object
4952
+ if post.existed?
4953
+ Search.reindex(post) if post.title_changed? # update
4954
+ else
4955
+ post.create_default_associations! # first save
4956
+ end
4957
+ true
4958
+ end
4959
+ ```
4960
+
4961
+ **Lifecycle callback order.** Parse Server has no separate `beforeCreate` /
4962
+ `afterCreate` triggers — only `beforeSave` and `afterSave`. The SDK runs your
4963
+ model's ActiveModel callbacks in canonical order across the two webhooks:
4964
+
4965
+ ```
4966
+ beforeSave webhook : before_save → before_create (before_create only for new objects)
4967
+ [Parse Server persists]
4968
+ afterSave webhook : after_create → after_save (after_create only for new objects)
4969
+ ```
4970
+
4971
+ So a model `before_create` / `after_create` callback runs for objects created by
4972
+ **any** client (REST / JS cloud code / Auth0 / iOS), not just Ruby-model saves —
4973
+ provided the corresponding trigger is registered with Parse Server (see
4974
+ [Register Webhooks](#register-webhooks)). These callbacks fire **once** per save;
4975
+ Ruby-SDK-initiated saves run them locally and the webhook skips them to avoid
4976
+ double-firing. `:if`/`:unless` conditions on these callbacks are honored.
4977
+
4978
+ > **`before_update` / `after_update` do not run from webhooks.** The webhook
4979
+ > layer runs `before_save` / `before_create` / `after_create` / `after_save`
4980
+ > only. The `:update`-specific callbacks fire on Ruby-model saves but **not**
4981
+ > for client-initiated (REST / JS / Auth0) saves, because Parse Server has no
4982
+ > `beforeUpdate` / `afterUpdate` trigger. For update-time logic that must run
4983
+ > for all clients, use `before_save` / `after_save` and branch on `existed?`.
4984
+
4865
4985
  > **Keep `after_save` handlers fast.** Parse Server **waits** for the `after_save`
4866
4986
  > webhook response before returning to the saving client (only LiveQuery events
4867
4987
  > are truly fire-and-forget), so a slow handler adds latency to that client's
@@ -4872,6 +4992,80 @@ For any `after_*` hook, return values are not needed since Parse does not utiliz
4872
4992
  > most for client-initiated saves, where the callback runs inside the webhook —
4873
4993
  > Ruby-SDK saves run it in-process after their own REST response instead.
4874
4994
 
4995
+ #### Acting as the calling user
4996
+
4997
+ Parse Server includes the caller's live session token (`user.sessionToken`) in
4998
+ every trigger webhook fired by a logged-in user (it is absent for a master-key
4999
+ request). A handler can opt in to acting on the server **as that user** —
5000
+ authorized by Parse Server with full ACL, CLP, and `protectedFields`
5001
+ enforcement — instead of reaching for the application master key. Three opt-in
5002
+ handles are available on the `payload`:
5003
+
5004
+ | Handle | Returns | `nil` when |
5005
+ |---|---|---|
5006
+ | `payload.session_token` | the caller's raw token (`String`) | master-key request (no user) |
5007
+ | `payload.user_agent(**opts)` | a non-master `Parse::Agent` in **client mode**, token bound | no token |
5008
+ | `payload.user_client` | a non-master `Parse::Client` with the token **bound** | no token |
5009
+
5010
+ ```ruby
5011
+ Parse::Webhooks.route :after_save, :Post do
5012
+ next true unless session_token? # master-key save → no caller token
5013
+
5014
+ # A client-mode Parse::Agent scoped to the caller (read tools only unless
5015
+ # you pass allow_mutations: true). ACL/CLP enforced; no master-key fallback.
5016
+ visible = user_agent.execute(:query_class, class_name: "Post", limit: 20)
5017
+
5018
+ # …or a raw user-scoped Parse::Client. The token is BOUND, so plain REST
5019
+ # calls authorize as the user with no per-call session_token: argument:
5020
+ mine = user_client.request(:get, "classes/Post").result
5021
+ true
5022
+ end
5023
+ ```
5024
+
5025
+ The token is captured before the payload's credential scrubbing runs, so it is
5026
+ still removed from `payload.user` / `payload.object` and never appears in
5027
+ `payload.as_json`, `payload.inspect`, or the request log. `user_client` binds it
5028
+ via the `Parse::Client.new(session_token:)` option, applied as the
5029
+ lowest-priority auth fallback — an explicit per-call `session_token:`, a
5030
+ `Parse.with_session` block, or an explicit `use_master_key: true` all still take
5031
+ precedence. This applies to **webhook-delivered** triggers (including the model
5032
+ `webhook :before_save` DSL, which is HTTP-delivered); a genuine in-process
5033
+ ActiveModel `before_save :method` callback has no incoming request and therefore
5034
+ no caller token.
5035
+
5036
+ The same user-scoped client is available on the **client side**. Two shapes:
5037
+
5038
+ ```ruby
5039
+ # 1. A client object you can pass around (carries the user's token + the
5040
+ # server connection, no master key):
5041
+ client = Parse::User.login(username, password).session_client
5042
+ Parse::Query.new("Post", client: client).results # runs as the user
5043
+ # (Parse.client.become(token) builds the same thing from any token.)
5044
+
5045
+ # 2. A block that runs ordinary model operations as the user, without
5046
+ # threading session_token: through each call:
5047
+ Parse::User.login(username, password).with_session do
5048
+ Post.query.count # counts only the user's readable Posts
5049
+ Post.create(title: "Hi") # created under the user's permissions
5050
+ end
5051
+ ```
5052
+
5053
+ `with_session` (on a `Parse::User`, a `Parse::Client`, or `Parse.with_session`)
5054
+ scopes by binding the token as the ambient session — it authorizes
5055
+ **REST-routed** operations (`find` / `get` / `count` / `save`) as the user. It
5056
+ does **not** scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
5057
+ search): those resolve auth from the query's own `session_token:` / `acl_user:`
5058
+ and otherwise run in **master** mode, so scope them explicitly with a per-query
5059
+ `session_token:` or a scoped `Parse::Agent`. (To run a query as a user *without*
5060
+ a token — via the master key and SDK-side ACL simulation — use
5061
+ `Parse::Query#scope_to_user(user)`.) `Parse::Client#anonymous` builds the same
5062
+ non-master client with no token, for an explicitly unauthenticated request.
5063
+
5064
+ To explore a server as a specific user from this repo, `rake client:console`
5065
+ opens an IRB session whose default client is a non-master client bound to a user
5066
+ (session token, login, or anonymous), so every query in the session runs with
5067
+ that user's ACL/CLP — with `whoami` and `as_master { … }` helpers.
5068
+
4875
5069
  `before_save` and `before_delete` hooks have special functionality and multiple ways to halt operations:
4876
5070
 
4877
5071
  1. **Using `error!` method**: Calling `error!` will return an error response to Parse Server