parse-stack-next 5.2.1 → 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.
data/docs/mcp_guide.md CHANGED
@@ -3106,6 +3106,66 @@ Four different refusal reasons each produce a distinct `:error_code` and message
3106
3106
 
3107
3107
  **Resolution order at dispatch:** operator filter ▷ mutation gate ▷ mode ceiling ▷ in-tool class gate. Operator-filter precedence is deliberate — when a tool is excluded by both the operator's `tools: { except: [...] }` AND the mutation gate (or the mode ceiling), the operator-filter message wins so the operator looks at the right knob first. The mode-ceiling message names the tool, not the class — even when the request would have hit an `agent_hidden` class, the ceiling fires first for a refused tool, so the LLM does not learn anything about the class. For tools that pass the ceiling (e.g. `query_class`) the in-tool `assert_class_accessible!` runs next and the `agent_hidden` message echoes the class name supplied by the caller.
3108
3108
 
3109
+ ### Client mode from a webhook — run a handler as the calling user (v5.3.0)
3110
+
3111
+ Parse Server includes the caller's live session token (`user.sessionToken`) in
3112
+ every trigger webhook fired by a logged-in user (it is absent for a master-key
3113
+ request). `Parse::Webhooks::Payload` captures that token before scrubbing it out
3114
+ of `payload.user` / `payload.object` (so it never lands in `payload.as_json` or
3115
+ the request log) and exposes two opt-in, user-scoped handles — the webhook
3116
+ counterpart of constructing a client-mode agent by hand:
3117
+
3118
+ ```ruby
3119
+ Parse::Webhooks.route(:after_save, "Post") do
3120
+ # self is the Parse::Webhooks::Payload.
3121
+ next true unless session_token? # master-key save → no caller token
3122
+
3123
+ # A client-mode Parse::Agent bound to the caller — ACL/CLP enforced, no
3124
+ # master-key fallback. Same posture as Parse::Agent.new(session_token:, client: <no master_key>).
3125
+ visible = user_agent.execute(:query_class, class_name: "Post", limit: 20)
3126
+
3127
+ # …or a raw user-scoped Parse::Client (token is BOUND, so plain REST calls
3128
+ # are authorized as the user with no per-call session_token: needed):
3129
+ mine = user_client.request(:get, "classes/Post").result
3130
+ true
3131
+ end
3132
+ ```
3133
+
3134
+ | Payload handle | Returns | `nil` when |
3135
+ |----------------|---------|-----------|
3136
+ | `payload.session_token` | the caller's raw token (`String`) | master-key request (no user) |
3137
+ | `payload.user_agent(**opts)` | non-master `Parse::Agent` in **client mode**, token bound | no token |
3138
+ | `payload.user_client` | non-master `Parse::Client` with the token **bound** | no token |
3139
+
3140
+ `user_client` binds the token via the new `Parse::Client.new(session_token:)`
3141
+ option, applied as the lowest-priority auth fallback on every request — an
3142
+ explicit per-call `session_token:`, a `Parse.with_session` block, or an explicit
3143
+ `use_master_key: true` all still take precedence. Everything the Client Mode
3144
+ ceiling above says about a hand-built client-mode agent applies verbatim to
3145
+ `payload.user_agent`: read tools only unless `allow_mutations: true`, and
3146
+ `acl_user:` / `acl_role:` are not available on a no-master client.
3147
+
3148
+ The same user-scoped client is available client-side from a login
3149
+ (`Parse::User#session_client`, or `Parse.client.become(token)` from any token),
3150
+ and `Parse::User#with_session` / `Parse::Client#with_session` run a block as the
3151
+ user so ordinary model operations are implicitly scoped:
3152
+
3153
+ ```ruby
3154
+ client = Parse::User.login(username, password).session_client # non-master, token bound
3155
+ Parse::Query.new("Post", client: client).results # query as the user
3156
+ Parse::User.login(username, password).with_session { Post.query.count }
3157
+ ```
3158
+
3159
+ `with_session` (and `Parse.with_session`) authorize **REST-routed** operations
3160
+ (`find` / `get` / `count` / `save`) as the user. Mongo-direct queries
3161
+ (`results_direct`, `aggregate`, Atlas search) do NOT pick up the ambient
3162
+ session — they resolve auth from the query's own `session_token:` / `acl_user:`
3163
+ and otherwise run in **master** mode (a full master read, not anonymous), so
3164
+ scope them explicitly with a per-query `session_token:` or a scoped
3165
+ `Parse::Agent`. This is deliberate: mongo-direct scoping is always explicit in
3166
+ this SDK, so the ambient fiber state can never silently flip a mongo-direct
3167
+ query into user scope (or be mistaken for it).
3168
+
3109
3169
  ---
3110
3170
 
3111
3171
  ## `agent_hidden` — Per-Class Agent-Surface Denial
data/lib/parse/client.rb CHANGED
@@ -331,7 +331,86 @@ module Parse
331
331
  attr_accessor :cache
332
332
  attr_writer :retry_limit
333
333
  attr_reader :application_id, :api_key, :master_key, :server_url
334
+ # @return [String, nil] the session token bound to this client, if any
335
+ # (see the `:session_token` constructor option). Applied as the
336
+ # lowest-priority auth fallback on every request.
337
+ attr_reader :session_token
334
338
  alias_method :app_id, :application_id
339
+
340
+ # Redacted inspection. The default Ruby `#inspect` would dump every ivar,
341
+ # exposing the master key and any bound session token in cleartext wherever
342
+ # a client is logged or surfaced in an error reporter. Show only the
343
+ # connection identity and a boolean for each credential's presence.
344
+ def inspect
345
+ "#<#{self.class.name} server_url=#{@server_url.inspect} " \
346
+ "app_id=#{@application_id.inspect} master_key=#{@master_key ? "[FILTERED]" : "nil"} " \
347
+ "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
348
+ end
349
+
350
+ # A NEW non-master {Parse::Client} that mirrors THIS client's connection
351
+ # settings (`server_url` / `application_id` / `api_key`) but carries no
352
+ # master key and binds +session_token+, so it acts on the server as that
353
+ # user (ACL / CLP / `protectedFields` enforced, no master-key fallback).
354
+ # This is the general primitive behind {Parse::Webhooks::Payload#user_client}
355
+ # and {Parse::User#session_client}: derive a user-scoped client from a
356
+ # configured (e.g. master) client without re-specifying the connection.
357
+ #
358
+ # user_client = Parse.client.become(user.session_token)
359
+ # Parse::Query.new("Post", client: user_client).results # as the user
360
+ #
361
+ # @param session_token [String, #session_token] the token to bind. A blank
362
+ # token yields a tokenless non-master client (anonymous REST).
363
+ # @return [Parse::Client]
364
+ def become(session_token)
365
+ Parse::Client.new(
366
+ server_url: @server_url,
367
+ app_id: @application_id,
368
+ api_key: @api_key,
369
+ master_key: nil,
370
+ session_token: session_token,
371
+ )
372
+ end
373
+
374
+ # A NEW anonymous client that mirrors THIS client's connection but carries
375
+ # neither a master key nor a session token — every request it makes is
376
+ # unauthenticated (app-id + REST key only). Use it to drop the bound user
377
+ # identity for a one-off public read without mutating a shared client.
378
+ # Equivalent to {#become} with no token.
379
+ # @return [Parse::Client]
380
+ def anonymous
381
+ become(nil)
382
+ end
383
+
384
+ # Run a block with this client's bound {#session_token} active as the
385
+ # ambient session, so every query / object operation inside it that resolves
386
+ # the default client (e.g. `Post.count`, `Post.all`, `obj.save`) is
387
+ # authorized by Parse Server as that user — ACL and CLP enforced, master key
388
+ # suppressed — without threading `session_token:` through each call.
389
+ #
390
+ # This is the client-receiver flavor of {Parse.with_session} (and mirrors
391
+ # {Parse::User#with_session}); it scopes by binding the token as the AMBIENT
392
+ # session — it does not re-route operations through this client object, so
393
+ # the connection used inside the block is still the resolved default client.
394
+ # If you need operations to run against a different client, pass that client
395
+ # explicitly (e.g. `Parse::Query.new("Post", client: #{become}(...))`).
396
+ #
397
+ # total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
398
+ #
399
+ # Scopes REST-routed operations (`find` / `get` / `count` / `save`). It does
400
+ # NOT scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
401
+ # search): those resolve auth from the query's own `session_token:` /
402
+ # `acl_user:` and, absent that, run in MASTER mode — so a mongo-direct read
403
+ # inside this block is a full master read, not anonymous. Scope mongo-direct
404
+ # explicitly with a per-query `session_token:` or a scoped {Parse::Agent}.
405
+ #
406
+ # @raise [ArgumentError] if this client has no bound session token (scoping
407
+ # would be a no-op and almost certainly a mistake).
408
+ # @return the value of the block.
409
+ def with_session(&block)
410
+ raise ArgumentError, "Parse::Client#with_session requires a block" unless block_given?
411
+ raise ArgumentError, "Parse::Client#with_session requires a client with a bound session_token" if @session_token.nil?
412
+ Parse.with_session(@session_token, &block)
413
+ end
335
414
  # The client can support multiple sessions. The first session created, will be placed
336
415
  # under the default session tag. The :default session will be the default client to be used
337
416
  # by the other classes including Parse::Query and Parse::Objects
@@ -415,6 +494,14 @@ module Parse
415
494
  # @option opts [String] :master_key The Parse application master key (optional).
416
495
  # If this key is set, it will be sent on every request sent by the client
417
496
  # and your models. Defaults to ENV['PARSE_SERVER_MASTER_KEY'].
497
+ # @option opts [String] :session_token An optional session token bound to
498
+ # this client. When set, every request that does not pass an explicit
499
+ # `session_token:` / `use_master_key: true` and is not inside a
500
+ # `Parse.with_session` block sends this token (and suppresses the master
501
+ # key), so the client transparently acts as that user. Precedence is
502
+ # explicit per-call > `Parse.with_session` ambient > this bound token.
503
+ # Typically paired with `master_key: nil` to build a user-scoped client
504
+ # (see {Parse::Webhooks::Payload#user_client}).
418
505
  # @option opts [Boolean, Symbol] :logging Controls request/response logging.
419
506
  # - `true` - Enable logging at :info level
420
507
  # - `:debug` - Enable verbose logging with headers and body content
@@ -492,7 +579,26 @@ module Parse
492
579
  @server_url = opts[:server_url] || ENV["PARSE_SERVER_URL"] || Parse::Protocol::SERVER_URL
493
580
  @application_id = opts[:application_id] || opts[:app_id] || ENV["PARSE_SERVER_APPLICATION_ID"] || ENV["PARSE_APP_ID"]
494
581
  @api_key = opts[:api_key] || opts[:rest_api_key] || ENV["PARSE_SERVER_REST_API_KEY"] || ENV["PARSE_API_KEY"]
495
- @master_key = opts[:master_key] || ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
582
+ # Distinguish an explicit `master_key: nil` (deliberately a non-master
583
+ # client — what user_client / session_client / user_agent rely on) from
584
+ # an omitted key (fall back to ENV). The previous `opts[:master_key] ||
585
+ # ENV[...]` form silently re-inherited the process master key for the
586
+ # explicit-nil case, putting a "non-master" client back into master mode
587
+ # in any deployment that exports PARSE_SERVER_MASTER_KEY / PARSE_MASTER_KEY.
588
+ @master_key = if opts.key?(:master_key)
589
+ opts[:master_key]
590
+ else
591
+ ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
592
+ end
593
+ # Optional token bound to this client; applied per request as the
594
+ # lowest-priority auth fallback (see #request). Normalize blank/whitespace
595
+ # to nil so it never trips the "token present" branch at request time
596
+ # (where `present?` is false for whitespace) and silently fall back to the
597
+ # master key on a master-configured client.
598
+ bound_token = opts[:session_token]
599
+ bound_token = bound_token.session_token if bound_token.respond_to?(:session_token)
600
+ bound_token = bound_token.to_s.strip
601
+ @session_token = bound_token.empty? ? nil : bound_token
496
602
 
497
603
  @require_https = opts.fetch(:require_https, ENV["PARSE_REQUIRE_HTTPS"] == "true")
498
604
  @allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
@@ -1016,14 +1122,20 @@ module Parse
1016
1122
 
1017
1123
  token = opts[:session_token]
1018
1124
  # When no explicit token was passed AND the caller didn't ask to send
1019
- # the master key, fall through to the fiber-local ambient set by
1020
- # `Parse.with_session`. Explicit `use_master_key: true` is treated as
1021
- # a deliberate admin call and skips the ambient otherwise an
1022
- # `admin.do_thing(use_master_key: true)` nested inside a
1023
- # `with_session(user)` block would silently downgrade.
1125
+ # the master key, fall through to (in order) the fiber-local ambient set
1126
+ # by `Parse.with_session`, then this client's own bound `@session_token`.
1127
+ # Explicit `use_master_key: true` is treated as a deliberate admin call
1128
+ # and skips both — otherwise an `admin.do_thing(use_master_key: true)`
1129
+ # nested inside a `with_session(user)` block (or on a token-bound client)
1130
+ # would silently downgrade. The ambient wins over the bound token so a
1131
+ # `with_session` override inside a user-scoped client still takes effect.
1024
1132
  if token.nil? && !(explicit_master && opts[:use_master_key] == true)
1025
1133
  ambient = Parse.current_session_token
1026
- token = ambient if ambient.is_a?(String) && !ambient.empty?
1134
+ # A whitespace-only ambient must not count as present: otherwise it
1135
+ # blocks the bound-token fallback below and then fails the later
1136
+ # `token.present?` check, silently sending the master key instead.
1137
+ token = ambient if ambient.is_a?(String) && !ambient.strip.empty?
1138
+ token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token
1027
1139
  end
1028
1140
  if token.present?
1029
1141
  token = token.session_token if token.respond_to?(:session_token)
@@ -119,6 +119,23 @@ module Parse
119
119
 
120
120
  # These items are added as attributes with the special data type of :pointer
121
121
  def belongs_to(key, opts = {})
122
+ # An explicitly-passed `as:` that names a scalar data type
123
+ # (`:string`, `:integer`, `:boolean`, …) is almost always a mistake —
124
+ # you cannot point at a scalar — and most often means a `property`
125
+ # was written with `as:` out of habit. Reject it at declaration time.
126
+ # This is the only association footgun decidable without all models
127
+ # loaded: an unresolved *class* name may simply be a forward
128
+ # reference (the target is required later), so that check is deferred
129
+ # to {Parse.validate_associations!}.
130
+ explicit_as = opts.key?(:as) ? opts[:as] : nil
131
+ if explicit_as && Parse::Properties::TYPES.include?(explicit_as.to_s.to_sym)
132
+ scalar = explicit_as.to_s.to_sym
133
+ raise ArgumentError,
134
+ "#{self}##{key}: `as: #{explicit_as.inspect}` names the reserved data type " \
135
+ ":#{scalar}, not a Parse class. For a scalar column write " \
136
+ "`property #{key.inspect}, #{scalar.inspect}`; if you really mean a Parse class " \
137
+ "named #{scalar.to_s.camelize.inspect}, pass `class_name: #{scalar.to_s.camelize.inspect}` instead."
138
+ end
122
139
  opts = { as: key, field: key.to_s.camelize(:lower), required: false }.merge(opts)
123
140
  # `opts[:class_name]` is the explicit target Parse class name; it takes
124
141
  # precedence over the legacy `as: :symbol` shorthand (where the
@@ -134,6 +151,22 @@ module Parse
134
151
  set_attribute_method = :"#{key}_set_attribute!"
135
152
 
136
153
  if self.fields[key].present? && Parse::Properties::BASE_FIELD_MAP[key].nil?
154
+ existing_type = self.fields[key]
155
+ # A structural redeclaration that CHANGES the type to a pointer
156
+ # (e.g. a field first declared `property :owner, :string` and then
157
+ # `property :owner, as: :user` / `belongs_to :owner`) is almost
158
+ # always a bug — and, because `property … as:` now delegates here,
159
+ # it is the same silent-String failure mode this whole feature
160
+ # exists to fix. Honor the same `strict_property_redefinition`
161
+ # contract that `property` enforces so the conflict is not
162
+ # downgraded to a warning. A same-type reopen (existing :pointer)
163
+ # still just warns, matching the prior behavior.
164
+ if existing_type != :pointer && Parse.strict_property_redefinition
165
+ raise ArgumentError,
166
+ "#{self}##{key} is already defined as :#{existing_type}; refusing to " \
167
+ "redeclare it as a :pointer association (target #{klassName}). Set " \
168
+ "Parse.strict_property_redefinition = false to fall back to warn-and-ignore."
169
+ end
137
170
  warn "Belongs relation #{self}##{key} already defined with type #{klassName}"
138
171
  return false
139
172
  end
@@ -151,6 +184,20 @@ module Parse
151
184
  # Mapping between local attribute name and the remote column name
152
185
  self.field_map.merge!(key => parse_field)
153
186
 
187
+ # Agent metadata: a belongs_to pointer can carry a semantic description
188
+ # (and per-value enum descriptions) just like a `property` can. This
189
+ # also lets `property :x, as: :user, _description: "..."` round-trip its
190
+ # metadata through the delegation in Parse::Properties#property.
191
+ if opts[:_description].present?
192
+ self.property_descriptions[key] = opts[:_description].to_s.freeze
193
+ end
194
+ if opts[:_enum].is_a?(Hash) && opts[:_enum].any?
195
+ normalized = opts[:_enum].each_with_object({}) do |(value, desc), h|
196
+ h[value.to_s] = desc.to_s.freeze
197
+ end
198
+ self.property_enum_descriptions[key] = normalized.freeze
199
+ end
200
+
154
201
  # used for dirty tracking
155
202
  define_attribute_methods key
156
203
 
@@ -835,6 +835,26 @@ module Parse
835
835
  @session
836
836
  end
837
837
 
838
+ # A non-master {Parse::Client} bound to this user's session token, for
839
+ # acting on the server *as this user* with full ACL / CLP / +protectedFields+
840
+ # enforcement and no master-key fallback. It mirrors the connection settings
841
+ # of +base+ (the configured client by default) but carries no master key and
842
+ # binds {#session_token}, so even raw REST calls through it are authorized as
843
+ # the user with no per-call ceremony. The web-counterpart of
844
+ # {Parse::Webhooks::Payload#user_client}; the typical client-side entry point
845
+ # is right after a login:
846
+ #
847
+ # client = Parse::User.login(username, password).session_client
848
+ # Parse::Query.new("Post", client: client).results # scoped to the user
849
+ #
850
+ # @param base [Parse::Client] the client whose connection settings to mirror.
851
+ # @return [Parse::Client, nil] +nil+ when the user has no session token
852
+ # (e.g. fetched/saved under the master key rather than logged in).
853
+ def session_client(base = self.client)
854
+ return nil if @session_token.nil? || @session_token.to_s.strip.empty?
855
+ base.become(@session_token)
856
+ end
857
+
838
858
  # @!visibility private
839
859
  # Keys that must never flow through +Parse::User.create+ from a
840
860
  # mass-assigned hash. +authData+ on the user-signup endpoint causes
@@ -0,0 +1,30 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ # Global `const_missing` hook that lazily resolves the plural form of a
6
+ # {Parse::Object} subclass constant to that class. Referencing `Posts`
7
+ # when a class `Post` exists installs `Posts` as an alias for `Post` on
8
+ # the referencing module and returns it, so query entry points like
9
+ # `Posts.where(...).count` work without any per-model boilerplate.
10
+ #
11
+ # The hook is prepended onto `Module` so it applies to constant lookups
12
+ # in any namespace (top-level and nested). It is tightly guarded: every
13
+ # path that is not a plural-of-a-Parse-class falls through to `super`,
14
+ # preserving normal `NameError` and autoloader (Zeitwerk/classic)
15
+ # behavior. The whole feature is gated on {Parse.pluralized_aliases?} so
16
+ # opting out (`Parse.pluralized_aliases = false`) makes this a near-zero
17
+ # cost pass-through.
18
+ #
19
+ # @see Parse.pluralized_aliases
20
+ # @see Parse.__pluralized_alias_for
21
+ module PluralizedAliases
22
+ def const_missing(name)
23
+ klass = Parse.__pluralized_alias_for(self, name) if defined?(Parse)
24
+ return klass unless klass.nil?
25
+ super
26
+ end
27
+ end
28
+ end
29
+
30
+ Module.prepend(Parse::PluralizedAliases)
@@ -197,6 +197,33 @@ module Parse
197
197
  # data_type = :timezone if key == :time_zone || key == :timezone
198
198
  end
199
199
 
200
+ # A property that names a pointer target — via `as:`/`class_name:` or an
201
+ # explicit :pointer data type — is really a belongs_to association, not a
202
+ # scalar column. Delegate so `property :rejected_by, as: :user` behaves
203
+ # identically to `belongs_to :rejected_by, as: :user` instead of silently
204
+ # storing a String (the `as:` option was previously dropped, leaving the
205
+ # field as the default :string type). Reusing belongs_to keeps the
206
+ # className-trust guard and autofetch/dirty-tracking in one place rather
207
+ # than duplicating pointer handling here. `opts` is still raw user input
208
+ # at this point (the defaults merge happens further down), so only an
209
+ # explicitly-passed :field is forwarded and belongs_to defaults the rest.
210
+ if opts.key?(:as) || opts.key?(:class_name) || data_type == :pointer
211
+ forwarded = %i[as class_name field required _description _enum]
212
+ bt_opts = {}
213
+ forwarded.each { |o| bt_opts[o] = opts[o] if opts.key?(o) }
214
+ # belongs_to has no scalar-property machinery (defaults, symbolize,
215
+ # enum validation, alias toggles, scope generation). Surface — rather
216
+ # than silently drop — any such option so a `default:`/`symbolize:` on
217
+ # a pointer property isn't quietly ignored.
218
+ dropped = opts.keys.map(&:to_sym) - forwarded
219
+ unless dropped.empty?
220
+ warn "[#{self}] property #{key.inspect} resolves to a pointer association; " \
221
+ "ignoring unsupported option(s) #{dropped.map(&:inspect).join(', ')} " \
222
+ "(not available on belongs_to)."
223
+ end
224
+ return belongs_to(key, bt_opts)
225
+ end
226
+
200
227
  data_type = :timezone if data_type == :string && (key == :time_zone || key == :timezone)
201
228
 
202
229
  # allow :bool for :boolean
@@ -120,6 +120,76 @@ module Parse
120
120
 
121
121
  alias_method :where, :query
122
122
 
123
+ # Define a pluralized constant alias for this class so the plural form
124
+ # can be used as a query entry point — e.g. `Posts.where(...).count`
125
+ # for a class `Post`. The alias is the same class object, so every
126
+ # class method (`where`, `query`, `count`, `find`, `all`, scopes)
127
+ # works through it and `Posts.parse_class` still returns `"Post"`.
128
+ #
129
+ # This is the explicit counterpart to the automatic
130
+ # {Parse.pluralized_aliases} behavior. Use it when automatic aliasing
131
+ # is disabled, when the class name ends in `s` (which the automatic
132
+ # path skips), when you want a custom plural, or for namespaced models
133
+ # (the alias is defined on the enclosing module, not at top level).
134
+ #
135
+ # @example default plural
136
+ # class Post < Parse::Object
137
+ # pluralized_alias! # defines ::Posts => Post
138
+ # end
139
+ #
140
+ # @example custom plural for a name ending in `s`
141
+ # class Status < Parse::Object
142
+ # pluralized_alias! :Statuses
143
+ # end
144
+ #
145
+ # @example namespaced model
146
+ # module Blog
147
+ # class Post < Parse::Object
148
+ # pluralized_alias! # defines Blog::Posts => Blog::Post
149
+ # end
150
+ # end
151
+ #
152
+ # @param constant_name [Symbol, String, nil] the plural constant to
153
+ # define; defaults to the ActiveSupport pluralization of the class's
154
+ # demodulized name.
155
+ # @raise [ArgumentError] if the target constant already exists and is
156
+ # not this class.
157
+ # @return [self, nil] self when an alias exists/was created; nil if the
158
+ # class is anonymous or the plural matches the singular name.
159
+ def pluralized_alias!(constant_name = nil)
160
+ base = name
161
+ return nil if base.nil?
162
+ parts = base.split("::")
163
+ short = parts.last
164
+ plural = (constant_name && constant_name.to_s) || short.pluralize
165
+ return nil if plural == short
166
+ # NOTE: bare `Object` here would lexically resolve to `Parse::Object`
167
+ # (we are inside module Parse::Core::Querying), so the alias must be
168
+ # anchored at the true top level with `::Object`.
169
+ parent = parts.length > 1 ? parts[0..-2].join("::").constantize : ::Object
170
+ if parent.const_defined?(plural.to_sym, false)
171
+ existing = parent.const_get(plural.to_sym)
172
+ return self if existing.equal?(self)
173
+ # A code reloader (Zeitwerk in development) swaps `self` for a fresh
174
+ # class object but does not clean up the alias constant we set — it
175
+ # owns no autoload entry for it. On re-run of the class body the
176
+ # plural still points at the now-orphaned previous class. Re-point it
177
+ # to the current class instead of raising on every reload. Only a
178
+ # genuinely foreign constant (not a Parse model mapping to the same
179
+ # remote class) is treated as a conflict.
180
+ stale_reload = existing.is_a?(Class) && existing < Parse::Object &&
181
+ existing.parse_class == parse_class
182
+ unless stale_reload
183
+ raise ArgumentError,
184
+ "Cannot define pluralized alias #{plural} for #{base}: " \
185
+ "constant already defined as #{existing}."
186
+ end
187
+ parent.send(:remove_const, plural.to_sym)
188
+ end
189
+ parent.const_set(plural.to_sym, self)
190
+ self
191
+ end
192
+
123
193
  # @param conditions (see Parse::Query#where)
124
194
  # @return (see Parse::Query#where)
125
195
  # @see Parse::Query#where
@@ -734,10 +734,43 @@ module Parse
734
734
  ATTRIBUTES
735
735
  end
736
736
 
737
- # @return [Boolean] Two files are equal if they have the same url
737
+ # The value used to decide whether two {Parse::File}s refer to the same
738
+ # underlying file -- for equality ({#==}) and, through it, for dirty
739
+ # tracking (the property setter compares files with `==` to decide whether
740
+ # a `:file` field changed).
741
+ #
742
+ # Today this is the bare canonical {#url}: signed-URL query parameters are
743
+ # stripped into `@presigned_url` and `force_ssl` coercion is applied, so two
744
+ # files at the same storage location compare equal regardless of how the URL
745
+ # was signed or whether it was `http`/`https`. The URL is the best identity
746
+ # signal currently available -- Parse Server's S3 files adapter does not
747
+ # surface a content digest (ETag / MD5 / sha256) through `Parse::File`.
748
+ #
749
+ # FUTURE DIRECTION: when a files adapter can expose a content hash, this is
750
+ # the single seam to override so equality keys off file *content* instead of
751
+ # URL -- e.g. a custom `Parse::File` subclass or adapter shim returning the
752
+ # S3 ETag / sha256 here. Overriding this one method updates {#==} (and a
753
+ # future `#eql?`/`#hash` pair, if added) without touching dirty tracking.
754
+ # No content-hash source exists yet, so the URL is authoritative for now.
755
+ # @return [String, nil]
756
+ def content_signature
757
+ url
758
+ end
759
+
760
+ # @return [Boolean] Two files are equal when their {#content_signature}
761
+ # matches. Both sides go through the same reader, so the comparison is
762
+ # symmetric and force_ssl-consistent: the previous `@url == u.url` form
763
+ # compared one side's raw stored URL against the other's normalized
764
+ # reader, so two files at the same location read as unequal whenever
765
+ # {Parse::File.force_ssl} coerced one side from `http://` to `https://`
766
+ # (and `a == b` disagreed with `b == a`). Because the default signature is
767
+ # the bare canonical URL (signed-URL query parameters stripped into
768
+ # `@presigned_url`), a freshly re-signed URL for the same object is equal
769
+ # while a different underlying location is not. See {#content_signature}
770
+ # for the content-hash override seam.
738
771
  def ==(u)
739
772
  return false unless u.is_a?(self.class)
740
- @url == u.url
773
+ content_signature == u.content_signature
741
774
  end
742
775
 
743
776
  # Allows mass assignment from a Parse JSON hash.
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.2.1"
9
+ VERSION = "5.3.0"
10
10
  end
11
11
  end