parse-stack-next 5.1.1 → 5.2.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.env.sample +12 -0
  3. data/.env.test +4 -4
  4. data/CHANGELOG.md +545 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +6 -1
  7. data/README.md +167 -38
  8. data/Rakefile +56 -10
  9. data/docs/atlas_vector_search_guide.md +110 -9
  10. data/docs/mcp_guide.md +433 -0
  11. data/docs/mongodb_direct_guide.md +66 -1
  12. data/docs/mongodb_index_optimization_guide.md +22 -1
  13. data/docs/usage_guide.md +15 -0
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +90 -19
  16. data/lib/parse/agent/describe.rb +1 -0
  17. data/lib/parse/agent/errors.rb +16 -0
  18. data/lib/parse/agent/mcp_client.rb +9 -0
  19. data/lib/parse/agent/mcp_dispatcher.rb +139 -7
  20. data/lib/parse/agent/mcp_rack_app.rb +621 -17
  21. data/lib/parse/agent/mcp_subscriptions.rb +607 -0
  22. data/lib/parse/agent/metadata_dsl.rb +58 -0
  23. data/lib/parse/agent/metadata_registry.rb +141 -1
  24. data/lib/parse/agent/prompt_hardening.rb +213 -0
  25. data/lib/parse/agent/result_formatter.rb +18 -3
  26. data/lib/parse/agent/tools.rb +167 -24
  27. data/lib/parse/agent.rb +692 -21
  28. data/lib/parse/client/request.rb +55 -4
  29. data/lib/parse/client/response.rb +4 -0
  30. data/lib/parse/client.rb +205 -7
  31. data/lib/parse/model/classes/installation.rb +27 -10
  32. data/lib/parse/model/classes/user.rb +8 -0
  33. data/lib/parse/model/core/actions.rb +58 -4
  34. data/lib/parse/model/core/embed_managed.rb +19 -14
  35. data/lib/parse/model/core/indexing.rb +108 -16
  36. data/lib/parse/model/core/querying.rb +29 -0
  37. data/lib/parse/model/model.rb +34 -3
  38. data/lib/parse/model/object.rb +1 -0
  39. data/lib/parse/query.rb +90 -24
  40. data/lib/parse/retrieval/agent_tool.rb +369 -0
  41. data/lib/parse/retrieval/chunk.rb +74 -0
  42. data/lib/parse/retrieval/chunker.rb +208 -0
  43. data/lib/parse/retrieval/retriever.rb +274 -0
  44. data/lib/parse/retrieval.rb +10 -0
  45. data/lib/parse/schema.rb +69 -20
  46. data/lib/parse/stack/version.rb +2 -2
  47. data/parse-stack-next.gemspec +1 -1
  48. data/scripts/docker/docker-compose.atlas.yml +14 -10
  49. data/scripts/docker/docker-compose.test.yml +24 -20
  50. data/scripts/docker/mongo-init.js +3 -3
  51. data/scripts/start-parse.sh +10 -0
  52. data/scripts/start_mcp_server.rb +1 -1
  53. data/scripts/test_server_connection.rb +1 -1
  54. data/scripts/vector_prototype/create_vector_index.js +1 -1
  55. data/scripts/vector_prototype/fetch_embeddings.py +2 -2
  56. data/scripts/vector_prototype/query_prototype.rb +1 -1
  57. data/scripts/vector_prototype/run.sh +4 -4
  58. metadata +10 -2
@@ -46,12 +46,38 @@ module Parse
46
46
  # @!attribute [rw] idempotent_methods
47
47
  # @return [Array<Symbol>] HTTP methods that should include request IDs
48
48
  attr_accessor :idempotent_methods
49
+
50
+ # @!attribute [rw] assume_server_idempotency
51
+ # @return [Boolean] operator assertion that the Parse Server is
52
+ # configured with `idempotencyOptions` covering the write paths the
53
+ # SDK targets. When true, a request that carries a stable
54
+ # `X-Parse-Request-Id` header becomes safe for {Parse::Client} to
55
+ # transparently RETRY on an ambiguous failure (500/503/dropped
56
+ # connection) even when it is a POST or an atomic-op write — Parse
57
+ # Server deduplicates the replay server-side, so the write applies AT
58
+ # MOST ONCE.
59
+ #
60
+ # The replay does NOT transparently return the original response,
61
+ # though: Parse Server rejects the duplicate with error 159, which the
62
+ # SDK raises as {Parse::Error::DuplicateRequestError}. A caller relying
63
+ # on this retry must rescue that error (the original write already
64
+ # landed) and re-fetch by its own key if it needs the result.
65
+ #
66
+ # Default false. Sending the `X-Parse-Request-Id` header is harmless
67
+ # on its own, but ASSUMING the server deduplicates when it does not
68
+ # would double-apply the write on retry. Only set this true when
69
+ # Parse Server's `idempotencyOptions` is actually configured to cover
70
+ # those paths (it is OFF by default on Parse Server).
71
+ attr_accessor :assume_server_idempotency
49
72
  end
50
73
 
51
74
  # Default configuration
52
75
  self.enable_request_id = true # Enabled by default for production safety
53
76
  self.request_id_header = "X-Parse-Request-Id" # Standard Parse header
54
77
  self.idempotent_methods = [:post, :put, :patch] # Methods that can benefit from idempotency
78
+ # OFF by default: the client cannot know whether the server deduplicates,
79
+ # so it never assumes retry-safety for writes unless the operator opts in.
80
+ self.assume_server_idempotency = false
55
81
 
56
82
  # Creates a new request
57
83
  # @param method [String] the HTTP method
@@ -121,11 +147,23 @@ module Parse
121
147
 
122
148
  return unless should_use_request_id
123
149
 
150
+ header_name = self.class.request_id_header
151
+
152
+ # If a request id is already on the headers — e.g. a retry re-builds the
153
+ # Request with the same headers hash carried over from the first attempt
154
+ # — adopt it so the `request_id` ivar matches the value actually on the
155
+ # wire. Generating a fresh UUID here while the `||=` below leaves the old
156
+ # header in place would silently diverge the ivar from the sent header.
157
+ existing = @headers[header_name]
158
+ if existing && !existing.to_s.empty?
159
+ @request_id = existing
160
+ return
161
+ end
162
+
124
163
  # Use custom request ID if provided, otherwise generate one
125
164
  @request_id = @opts[:request_id] || generate_request_id
126
165
 
127
166
  # Add request ID to headers if not already present
128
- header_name = self.class.request_id_header
129
167
  @headers[header_name] ||= @request_id
130
168
  end
131
169
 
@@ -209,25 +247,38 @@ module Parse
209
247
  # Enables request ID generation globally
210
248
  # @param methods [Array<Symbol>] HTTP methods to apply idempotency to
211
249
  # @param header [String] header name to use for request IDs
212
- def self.enable_idempotency!(methods: [:post, :put, :patch], header: "X-Parse-Request-Id")
250
+ # @param assume_server_dedup [Boolean, nil] when non-nil, also sets
251
+ # {assume_server_idempotency} — pass `true` ONLY when Parse Server's
252
+ # `idempotencyOptions` is configured, to additionally make writes
253
+ # retry-safe. Leave nil (default) to send the header without changing
254
+ # the retry posture.
255
+ def self.enable_idempotency!(methods: [:post, :put, :patch], header: "X-Parse-Request-Id", assume_server_dedup: nil)
213
256
  self.enable_request_id = true
214
257
  self.idempotent_methods = methods
215
258
  self.request_id_header = header
259
+ self.assume_server_idempotency = assume_server_dedup unless assume_server_dedup.nil?
216
260
  end
217
261
 
218
- # Disables request ID generation globally
262
+ # Disables request ID generation globally. Also clears
263
+ # {assume_server_idempotency} so writes are never treated as retry-safe
264
+ # once the header is no longer sent.
219
265
  def self.disable_idempotency!
220
266
  self.enable_request_id = false
267
+ self.assume_server_idempotency = false
221
268
  end
222
269
 
223
270
  # Configures idempotency settings
224
271
  # @param enabled [Boolean] whether to enable idempotency
225
272
  # @param methods [Array<Symbol>] HTTP methods to apply idempotency to
226
273
  # @param header [String] header name to use for request IDs
227
- def self.configure_idempotency(enabled: true, methods: [:post, :put, :patch], header: "X-Parse-Request-Id")
274
+ # @param assume_server_dedup [Boolean] sets {assume_server_idempotency}
275
+ # (default false). Pass true ONLY when Parse Server `idempotencyOptions`
276
+ # is configured for the targeted paths.
277
+ def self.configure_idempotency(enabled: true, methods: [:post, :put, :patch], header: "X-Parse-Request-Id", assume_server_dedup: false)
228
278
  self.enable_request_id = enabled
229
279
  self.idempotent_methods = methods
230
280
  self.request_id_header = header
281
+ self.assume_server_idempotency = assume_server_dedup
231
282
  end
232
283
  end
233
284
  end
@@ -19,6 +19,10 @@ module Parse
19
19
  ERROR_TIMEOUT = 124
20
20
  # Code when the requests per second limit as been exceeded.
21
21
  ERROR_EXCEEDED_BURST_LIMIT = 155
22
+ # Code when a request carrying a previously-seen `X-Parse-Request-Id` is
23
+ # rejected by Parse Server's idempotency layer (the request was already
24
+ # applied). Surfaced as {Parse::Error::DuplicateRequestError}.
25
+ ERROR_DUPLICATE_REQUEST = 159
22
26
  # Code when a requested record is not found.
23
27
  ERROR_OBJECT_NOT_FOUND = 101
24
28
  # Code when the username is missing in request.
data/lib/parse/client.rb CHANGED
@@ -62,6 +62,17 @@ module Parse
62
62
  # An error when the session token provided in the request is invalid.
63
63
  class InvalidSessionTokenError < Error; end
64
64
 
65
+ # Raised when Parse Server's request-id idempotency layer rejects a request
66
+ # carrying a previously-seen `X-Parse-Request-Id` (Parse error code
67
+ # {Parse::Response::ERROR_DUPLICATE_REQUEST}, 159). The duplicate was NOT
68
+ # applied a second time — the original request already succeeded. The SDK
69
+ # reuses the same request id across transparent retries, so a retried write
70
+ # that lands but loses its response will surface THIS error on the replay:
71
+ # treat it as "the write already applied" (re-fetch by your own key if you
72
+ # need the resulting object — Parse Server does not echo the original
73
+ # response on a duplicate).
74
+ class DuplicateRequestError < Error; end
75
+
65
76
  # An error raised when a cloud function or job returns an error response
66
77
  # (e.g. when the cloud code calls error!()). Carries the function name,
67
78
  # Parse error code, HTTP status, and the underlying Response for debugging.
@@ -167,6 +178,40 @@ module Parse
167
178
  Parse::Client.client(conn)
168
179
  end
169
180
 
181
+ # Check that the Parse Server is reachable via the health endpoint.
182
+ # This is a no-credentials liveness probe — it does NOT validate the
183
+ # application_id or REST key. A server with a mistyped app_id still returns
184
+ # `true` here; use {.connected?} when you also need to validate credentials.
185
+ # @param conn [Symbol] the named client connection to probe. Defaults to :default.
186
+ # @return [Boolean] +true+ if the server responded with status "ok", +false+
187
+ # if the server is unreachable or returned an unexpected response.
188
+ def self.reachable?(conn = :default)
189
+ client(conn).reachable?
190
+ rescue StandardError
191
+ false
192
+ end
193
+
194
+ # Check that the Parse Server is reachable and responding.
195
+ # By default this probes the credential-less health endpoint, so it returns
196
+ # +true+ whenever the server is up (it does NOT, by itself, validate the
197
+ # application_id or REST key — see {Parse::Client#connected?} for why a
198
+ # data-class probe is unreliable on a hardened server). Pass an `endpoint`
199
+ # (e.g. `"classes/_User"`) to additionally exercise the auth stack against a
200
+ # class you know the configured key can read; a bad key then surfaces as an
201
+ # {Parse::Error::AuthenticationError} at the instance level, which the
202
+ # instance method converts to +false+. Any other failure is caught by this
203
+ # module-boundary rescue and converted to +false+.
204
+ # @param conn [Symbol] the named client connection to probe. Defaults to :default.
205
+ # @param endpoint [String, nil] optional path to probe instead of the health
206
+ # endpoint, to validate credentials against a readable class.
207
+ # @return [Boolean] +true+ if the server is reachable (and, when an endpoint
208
+ # is given, the credentials are accepted).
209
+ def self.connected?(conn = :default, endpoint = nil)
210
+ client(conn).connected?(endpoint)
211
+ rescue StandardError
212
+ false
213
+ end
214
+
170
215
  # The shared cache for the default client connection. This is useful if you want to
171
216
  # also utilize the same cache store for other purposes in your application.
172
217
  # This should normally be a {https://github.com/minad/moneta Moneta} unified
@@ -784,6 +829,50 @@ module Parse
784
829
  self.cache.clear if self.cache.present?
785
830
  end
786
831
 
832
+ # No-credentials liveness probe. Hits the Parse Server health endpoint and
833
+ # returns +true+ when the server responds with status "ok". No application
834
+ # credentials are required, so this passes even when the configured
835
+ # application_id or REST key is wrong. Use {#connected?} to also validate
836
+ # credentials.
837
+ # @return [Boolean] +true+ if the server is up and returned a healthy status.
838
+ def reachable?
839
+ response = request(:get, Parse::API::Server::SERVER_HEALTH_PATH, opts: { cache: false })
840
+ response.success?
841
+ rescue Parse::Error, Faraday::Error
842
+ false
843
+ end
844
+
845
+ # Connectivity probe. By default hits the Parse Server health endpoint —
846
+ # the same target as {#reachable?} — so it returns +true+ whenever the
847
+ # server is up, regardless of CLP configuration.
848
+ #
849
+ # Why not a `_User` find? A limit-0 find against +_User+ exercises the auth
850
+ # stack, but locking +_User+ finds to the master key via a Class-Level
851
+ # Permission is standard production hardening — on such a server the probe
852
+ # gets a permission error and (wrongly) reports "not connected" for a
853
+ # perfectly healthy, correctly-configured deployment. The default therefore
854
+ # avoids any data class.
855
+ #
856
+ # To ALSO validate credentials, pass `endpoint:` a path to a class the
857
+ # configured key is allowed to read (e.g. `"classes/_User"` on a default
858
+ # server, or one of your own readable classes). The probe runs `limit: 0`
859
+ # so it never pulls rows, and routes through the auth middleware, so a wrong
860
+ # application_id / REST key surfaces as an {Parse::Error::AuthenticationError}
861
+ # and is converted to +false+. Any connection, timeout, or API error returns
862
+ # +false+ rather than raising; genuine programming errors (e.g.
863
+ # +NoMethodError+) still propagate.
864
+ # @param endpoint [String, nil] optional path to probe instead of the
865
+ # health endpoint, to validate credentials against a readable class.
866
+ # @return [Boolean] +true+ if the server is reachable (and, when an endpoint
867
+ # is given, the credentials are accepted).
868
+ def connected?(endpoint = nil)
869
+ path = endpoint || Parse::API::Server::SERVER_HEALTH_PATH
870
+ response = request(:get, path, query: { limit: 0 }, opts: { cache: false })
871
+ response.success?
872
+ rescue Parse::Error, Faraday::Error
873
+ false
874
+ end
875
+
787
876
  # Send a REST API request to the server. This is the low-level API used for all requests
788
877
  # to the Parse server with the provided options. Every request sent to Parse through
789
878
  # the client goes through the configured set of middleware that can be modified by applying
@@ -824,6 +913,9 @@ module Parse
824
913
  # - This usually means you have exceeded the burst limit on requests, which will mean you will be throttled for the
825
914
  # next 60 seconds.
826
915
  # @raise Parse::Error::InvalidSessionTokenError when the Parse response code is 209.
916
+ # @raise Parse::Error::DuplicateRequestError when the Parse response code is
917
+ # {Parse::Response::ERROR_DUPLICATE_REQUEST} (159) — request-id idempotency
918
+ # rejected a duplicate; the original write already applied.
827
919
  # - This means the session token that was sent in the request seems to be invalid.
828
920
  # @return [Parse::Response] the response for this request.
829
921
  # @see Parse::Middleware::BodyBuilder
@@ -855,6 +947,11 @@ module Parse
855
947
  "Good: Parse.client.create_object('X', body, session_token: t, use_master_key: false)"
856
948
  end
857
949
 
950
+ # Retry budget. Initialized ONCE here, ABOVE the `begin` below, so the
951
+ # `retry` keyword in the rescue clauses (which re-runs only the begin
952
+ # block, not the whole method) preserves the countdown across attempts.
953
+ # If this initialization ran inside the begin it would reset on every
954
+ # attempt, turning a transient 500/503/429 into an infinite retry loop.
858
955
  _retry_count ||= self.retry_limit
859
956
 
860
957
  if opts[:retry] == false
@@ -863,6 +960,16 @@ module Parse
863
960
  _retry_count = opts[:retry]
864
961
  end
865
962
 
963
+ # The effective starting budget, captured ONCE after the opts override
964
+ # (and, like `_retry_count`, above the `begin` so `retry` doesn't reset
965
+ # it). The backoff multiplier is `(_retry_max - _retry_count)`, which
966
+ # grows 1, 2, 3, … as attempts are consumed. Deriving it from
967
+ # `self.retry_limit` instead would go to zero or negative whenever a
968
+ # caller passes `opts: { retry: N }` with N above the instance default,
969
+ # silently disabling the backoff (every retry firing at zero delay).
970
+ _retry_max ||= _retry_count
971
+
972
+ begin
866
973
  headers ||= {}
867
974
  # if the first argument is a Parse::Request object, then construct it
868
975
  _request = nil
@@ -970,39 +1077,130 @@ module Parse
970
1077
  elsif response.code == 209 # Error 209: invalid session token
971
1078
  Parse::Client._safe_warn("InvalidSessionTokenError", response)
972
1079
  raise Parse::Error::InvalidSessionTokenError, response
1080
+ elsif response.code == Parse::Response::ERROR_DUPLICATE_REQUEST # 159
1081
+ # Request-id idempotency rejected a duplicate — the original write
1082
+ # already applied (NOT a second time). Surface a typed, catchable
1083
+ # signal rather than a generic error; this is what a transparently-
1084
+ # retried write that landed-but-lost-its-response sees on the replay.
1085
+ Parse::Client._safe_warn("DuplicateRequestError", response)
1086
+ raise Parse::Error::DuplicateRequestError, response
973
1087
  end
974
1088
  end
975
1089
 
976
1090
  response
977
1091
  rescue Parse::Error::RequestLimitExceededError, Parse::Error::ServiceUnavailableError => e
978
- if _retry_count > 0
1092
+ # 429 (RequestLimitExceeded): the server threw the request away, so
1093
+ # re-sending is safe for any method. 500/503 (ServiceUnavailable) is
1094
+ # ambiguous — a write may have applied before the error — so only
1095
+ # re-send when the request is idempotent (see #idempotent_retry?).
1096
+ retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers)
1097
+ if _retry_count > 0 && retryable
979
1098
  warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response.request}"
980
1099
  _retry_count -= 1
981
- # Use Retry-After header if available, otherwise use exponential backoff
1100
+ # Use Retry-After header if available, otherwise use linear backoff
982
1101
  retry_after = response.retry_after if response.respond_to?(:retry_after)
983
1102
  if retry_after && retry_after > 0
984
1103
  _retry_delay = retry_after
985
1104
  warn "[Parse:Retry] Using Retry-After header: #{_retry_delay}s"
986
1105
  else
987
- # Deterministic exponential backoff with +/-25% jitter. Never zero —
1106
+ # Linear backoff (RETRY_DELAY × attempt number) with +/-25% jitter.
1107
+ # Never zero —
988
1108
  # zero-wait retries amplify DoS against upstream and stampede on 429.
989
- backoff_delay = RETRY_DELAY * (self.retry_limit - _retry_count)
1109
+ backoff_delay = RETRY_DELAY * (_retry_max - _retry_count)
990
1110
  _retry_delay = backoff_delay * (0.75 + rand * 0.5)
991
1111
  end
992
1112
  sleep _retry_delay if _retry_delay > 0
993
1113
  retry
994
1114
  end
995
1115
  raise
996
- rescue Faraday::ClientError, Net::OpenTimeout => e
997
- if _retry_count > 0
1116
+ rescue Faraday::ClientError, Faraday::TimeoutError, Net::OpenTimeout => e
1117
+ # Request timed out mid-flight: the outcome is unknown (the server may
1118
+ # have received and applied the write but never answered), so only
1119
+ # re-send idempotent requests to avoid double-applying.
1120
+ #
1121
+ # Faraday 2.x raises `Faraday::TimeoutError` for a read timeout
1122
+ # (`Timeout::Error` / `Errno::ETIMEDOUT`); it subclasses `Faraday::Error`,
1123
+ # not `ClientError`, so it must be listed explicitly to be caught. We
1124
+ # deliberately do NOT catch `Faraday::ConnectionFailed` (connection
1125
+ # refused/reset, plus the wrapped connect-timeout): refused is a
1126
+ # non-transient "server down / misconfigured" failure, and auto-retrying
1127
+ # it only adds backoff latency before the inevitable error. Broadening to
1128
+ # reset connections safely (retry reset, fail fast on refused) is tracked
1129
+ # as a follow-up.
1130
+ if _retry_count > 0 && idempotent_retry?(method, body, headers)
998
1131
  warn "[Parse:Retry] Retries remaining #{_retry_count} : #{_request}"
999
1132
  _retry_count -= 1
1000
- backoff_delay = RETRY_DELAY * (self.retry_limit - _retry_count)
1133
+ backoff_delay = RETRY_DELAY * (_retry_max - _retry_count)
1001
1134
  _retry_delay = backoff_delay * (0.75 + rand * 0.5)
1002
1135
  sleep _retry_delay if _retry_delay > 0
1003
1136
  retry
1004
1137
  end
1005
1138
  raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
1139
+ end
1140
+ end
1141
+
1142
+ # Whether a request whose outcome is UNKNOWN (a 500/503 or a dropped
1143
+ # connection) is safe to transparently re-send.
1144
+ #
1145
+ # Server-dedup fast path: when the operator has asserted Parse Server
1146
+ # idempotency is configured ({Parse::Request.assume_server_idempotency})
1147
+ # AND this request carries a stable `X-Parse-Request-Id` header, the
1148
+ # server deduplicates a replay, so even a POST or an atomic-op write is
1149
+ # safe to retry — the write applies at most once. The replay is NOT a
1150
+ # transparent success, though: Parse Server rejects the duplicate with
1151
+ # error 159, surfaced as a raised {Parse::Error::DuplicateRequestError}
1152
+ # the caller must rescue (the original write already landed). The SDK sends
1153
+ # the same request id on every retry (the header is set once and preserved
1154
+ # across the `retry`), which is what makes the server-side dedup match.
1155
+ #
1156
+ # Otherwise the conservative method/body heuristic applies: GET and DELETE
1157
+ # are idempotent; a full-object PUT update is idempotent ONLY when it
1158
+ # carries no atomic `__op` mutation (Increment/Add/AddUnique/Remove/Relation
1159
+ # would double-apply on replay); POST (object create / batch) is never
1160
+ # auto-retried. 429 throttles are handled at the call site, since the
1161
+ # server provably discarded the request and those re-send regardless.
1162
+ # @param method [Symbol] the (downcased) HTTP method of the request.
1163
+ # @param body [Hash, Object, nil] the request body.
1164
+ # @param headers [Hash, nil] the outgoing request headers (consulted for
1165
+ # the request-id header on the server-dedup fast path).
1166
+ # @return [Boolean]
1167
+ def idempotent_retry?(method, body, headers = nil)
1168
+ return true if server_deduped_request?(headers)
1169
+ case method
1170
+ when :get, :delete then true
1171
+ when :put then !body_carries_atomic_op?(body)
1172
+ else false
1173
+ end
1174
+ end
1175
+
1176
+ # Whether this request is covered by Parse Server's server-side request-id
1177
+ # deduplication, making a replay a no-op. True only when the operator has
1178
+ # opted in via {Parse::Request.assume_server_idempotency} AND the request
1179
+ # actually carries a non-blank request-id header (writes to inherently
1180
+ # non-idempotent paths — sessions, logout, functions, push, jobs — never
1181
+ # get a request id, so they correctly fail this check).
1182
+ # @param headers [Hash, nil] the outgoing request headers.
1183
+ # @return [Boolean]
1184
+ def server_deduped_request?(headers)
1185
+ return false unless Parse::Request.assume_server_idempotency
1186
+ return false unless headers.is_a?(Hash)
1187
+ rid = headers[Parse::Request.request_id_header]
1188
+ rid.is_a?(String) && !rid.strip.empty?
1189
+ end
1190
+
1191
+ # Whether a request body carries a Parse atomic operation, i.e. any field
1192
+ # whose value is a Hash with an `__op` key (Increment, Add, AddUnique,
1193
+ # Remove, AddRelation, RemoveRelation, Delete). Such ops are not idempotent
1194
+ # and must not be replayed on an ambiguous failure. Assumes the body is a
1195
+ # Ruby Hash, which the SDK's normal save/update path always provides; a
1196
+ # pre-serialized String body is treated as op-free (and therefore
1197
+ # retryable), so callers handing `request` a raw JSON string for a
1198
+ # PUT-with-op would bypass this guard.
1199
+ # @param body [Object] the request body.
1200
+ # @return [Boolean]
1201
+ def body_carries_atomic_op?(body)
1202
+ return false unless body.is_a?(Hash)
1203
+ body.any? { |_k, v| v.is_a?(Hash) && (v.key?("__op") || v.key?(:__op)) }
1006
1204
  end
1007
1205
 
1008
1206
  # Send a GET request.
@@ -85,22 +85,32 @@ module Parse
85
85
  class Installation < Parse::Object
86
86
  parse_class Parse::Model::CLASS_INSTALLATION
87
87
 
88
+ # The CLP operations Parse Server does NOT honor on `_Installation`:
89
+ # `find` and `delete` are hardcoded master-key-only at the REST layer,
90
+ # and `create`/`update` are gated on the `X-Parse-Installation-Id`
91
+ # header rather than CLP. Setting CLP for any of these either does
92
+ # nothing or breaks the SDK's device-registration flow, so the advisory
93
+ # fires only for them. `get`, `count`, `addField`, and `protectedFields`
94
+ # respond to CLP normally and are configured without a warning.
95
+ INEFFECTIVE_CLP_OPERATIONS = %i[find create update delete].freeze
96
+
88
97
  class << self
89
- # Override {Parse::Object.set_clp} on `_Installation` so that any
90
- # attempt to change CLP from the SDK emits a one-time advisory.
91
- # Parse Server hardcodes `find` and `delete` on `_Installation` to
92
- # master-key-only at the REST layer, and gates `create`/`update`
93
- # on the `X-Parse-Installation-Id` header rather than CLP — so
94
- # most CLP changes here either do nothing or break the SDK's
95
- # device-registration flow. Behavior is otherwise unchanged.
98
+ # Override {Parse::Object.set_clp} on `_Installation` so that an
99
+ # attempt to change CLP for an operation the server ignores emits a
100
+ # one-time advisory. CLP changes for `get` / `count` / `addField`
101
+ # take effect normally and are applied without a warning. Behavior is
102
+ # otherwise unchanged.
96
103
  def set_clp(operation, **opts)
97
- _warn_about_installation_clp!(:set_clp, operation)
104
+ _warn_about_installation_clp!(:set_clp, operation) if _installation_clp_ineffective?(operation)
98
105
  super
99
106
  end
100
107
 
101
- # Same advisory for the bulk-config DSL.
108
+ # Same advisory for the bulk-config DSL — warn only about the keys that
109
+ # name an ineffective operation, and stay silent when the caller only
110
+ # touches the operations CLP actually controls.
102
111
  def set_class_access(**ops_to_access)
103
- _warn_about_installation_clp!(:set_class_access, ops_to_access.keys)
112
+ offending = ops_to_access.keys.select { |op| _installation_clp_ineffective?(op) }
113
+ _warn_about_installation_clp!(:set_class_access, offending) unless offending.empty?
104
114
  super
105
115
  end
106
116
 
@@ -127,6 +137,13 @@ module Parse
127
137
  super
128
138
  end
129
139
 
140
+ # @!visibility private
141
+ # Whether a CLP operation is one Parse Server ignores on `_Installation`
142
+ # (and therefore worth warning about). Normalizes Strings/Symbols.
143
+ def _installation_clp_ineffective?(operation)
144
+ INEFFECTIVE_CLP_OPERATIONS.include?(operation.to_s.to_sym)
145
+ end
146
+
130
147
  # @!visibility private
131
148
  def _warn_about_installation_clp!(method, detail)
132
149
  return if @_installation_clp_warned
@@ -654,6 +654,10 @@ module Parse
654
654
 
655
655
  # Request a password reset for this user
656
656
  # @return [Boolean] true if it was successful requested. false otherwise.
657
+ # @raise [Parse::Error::ServiceUnavailableError] if Parse Server returns a
658
+ # 500/503 (e.g. no emailAdapter / publicServerURL configured). Callers that
659
+ # branch on the Boolean should rescue this; `if request_password_reset(email)`
660
+ # is not exception-safe on a misconfigured server.
657
661
  # @see Parse::User.request_password_reset
658
662
  def request_password_reset
659
663
  return false if email.nil?
@@ -1061,6 +1065,10 @@ module Parse
1061
1065
  # Parse::User.request_password_reset("user@example.com")
1062
1066
  # @param email [String] The user's email address.
1063
1067
  # @return [Boolean] True/false if successful.
1068
+ # @raise [Parse::Error::ServiceUnavailableError] if Parse Server returns a
1069
+ # 500/503 (e.g. no emailAdapter / publicServerURL configured). Callers that
1070
+ # branch on the Boolean should rescue this; `if request_password_reset(email)`
1071
+ # is not exception-safe on a misconfigured server.
1064
1072
  def self.request_password_reset(email)
1065
1073
  email = email.email if email.is_a?(Parse::User)
1066
1074
  return false if email.blank?
@@ -409,6 +409,13 @@ module Parse
409
409
  winner = _recover_from_duplicate_value(e, query_attrs, session: session, master_key: master_key)
410
410
  raise unless winner
411
411
  winner
412
+ rescue Parse::Error::DuplicateRequestError
413
+ # A transparently-retried create landed but lost its response;
414
+ # server idempotency rejected the replay. Re-find the row the
415
+ # original attempt created and return it.
416
+ winner = _recover_from_duplicate_request(query_attrs, session: session, master_key: master_key)
417
+ raise unless winner
418
+ winner
412
419
  end
413
420
  end
414
421
  end
@@ -471,6 +478,12 @@ module Parse
471
478
  winner = _recover_from_duplicate_value(e, query_attrs, session: session, master_key: master_key)
472
479
  raise unless winner
473
480
  obj = winner
481
+ rescue Parse::Error::DuplicateRequestError
482
+ # See #first_or_create! — recover the row a retried create
483
+ # already landed (it already carries resource_attrs).
484
+ winner = _recover_from_duplicate_request(query_attrs, session: session, master_key: master_key)
485
+ raise unless winner
486
+ obj = winner
474
487
  end
475
488
  end
476
489
 
@@ -480,7 +493,15 @@ module Parse
480
493
  end
481
494
  if has_changes
482
495
  obj.apply_attributes!(resource_attrs, dirty_track: true)
483
- session ? obj.save!(session: session) : obj.save!
496
+ begin
497
+ session ? obj.save!(session: session) : obj.save!
498
+ rescue Parse::Error::DuplicateRequestError
499
+ # A retried update (PUT) landed but lost its response; re-find
500
+ # the now-updated row and return it.
501
+ winner = _recover_from_duplicate_request(query_attrs, session: session, master_key: master_key)
502
+ raise unless winner
503
+ obj = winner
504
+ end
484
505
  end
485
506
  end
486
507
 
@@ -617,6 +638,21 @@ module Parse
617
638
  _scoped_first(query_attrs, session: session, master_key: master_key)
618
639
  end
619
640
 
641
+ # @!visibility private
642
+ # Recovery for a request-id idempotency duplicate
643
+ # ({Parse::Error::DuplicateRequestError}, Parse code 159): the create's
644
+ # POST was rejected as a duplicate, which means a prior — transparently
645
+ # retried — attempt already created the row but lost its response. Re-find
646
+ # the row by the identifying `query_attrs` and return it (the row was
647
+ # created with `query_attrs.merge(resource_attrs)`, so it already carries
648
+ # the resource attributes). Returns nil if it cannot be located, in which
649
+ # case the caller re-raises the original error. Relies on `query_attrs`
650
+ # actually identifying the row — the same assumption the duplicate-value
651
+ # recovery and `first_or_create!`'s own find already make.
652
+ def _recover_from_duplicate_request(query_attrs, session: nil, master_key: nil)
653
+ _scoped_first(query_attrs, session: session, master_key: master_key)
654
+ end
655
+
620
656
  # @!visibility private
621
657
  # The pre-synchronize behavior of `first_or_create!`, factored out so
622
658
  # the synchronize wrapper can short-circuit when disabled. Preserves
@@ -627,7 +663,13 @@ module Parse
627
663
  obj = self.new query_attrs.merge(resource_attrs)
628
664
  end
629
665
  if obj.new?
630
- session ? obj.save!(session: session) : obj.save!
666
+ begin
667
+ session ? obj.save!(session: session) : obj.save!
668
+ rescue Parse::Error::DuplicateRequestError
669
+ winner = _recover_from_duplicate_request(query_attrs, session: session, master_key: master_key)
670
+ raise unless winner
671
+ obj = winner
672
+ end
631
673
  end
632
674
  obj
633
675
  end
@@ -637,14 +679,26 @@ module Parse
637
679
  obj = _scoped_first(query_attrs, session: session, master_key: master_key)
638
680
  if obj.nil?
639
681
  obj = self.new query_attrs.merge(resource_attrs)
640
- session ? obj.save!(session: session) : obj.save!
682
+ begin
683
+ session ? obj.save!(session: session) : obj.save!
684
+ rescue Parse::Error::DuplicateRequestError
685
+ winner = _recover_from_duplicate_request(query_attrs, session: session, master_key: master_key)
686
+ raise unless winner
687
+ obj = winner
688
+ end
641
689
  elsif !resource_attrs.empty?
642
690
  has_changes = resource_attrs.any? do |key, value|
643
691
  obj.respond_to?(key) && obj.send(key) != value
644
692
  end
645
693
  if has_changes
646
694
  obj.apply_attributes!(resource_attrs, dirty_track: true)
647
- session ? obj.save!(session: session) : obj.save!
695
+ begin
696
+ session ? obj.save!(session: session) : obj.save!
697
+ rescue Parse::Error::DuplicateRequestError
698
+ winner = _recover_from_duplicate_request(query_attrs, session: session, master_key: master_key)
699
+ raise unless winner
700
+ obj = winner
701
+ end
648
702
  end
649
703
  end
650
704
  obj
@@ -47,27 +47,32 @@ module Parse
47
47
  # provider can happen any time before the first save. Declaration
48
48
  # never makes a network call.
49
49
  #
50
- # == Single vector per record (v5.0)
50
+ # == Single vector per record
51
51
  #
52
52
  # `embed` produces exactly one vector per record. All declared
53
53
  # source fields are concatenated (joined with "\n\n", blank values
54
- # skipped) and sent to the provider as a single string. There is
55
- # no built-in chunker in v5.0: long source text whose concatenation
56
- # exceeds the provider's per-call token budget will be truncated
57
- # provider-side, and the resulting vector will represent only the
58
- # leading portion of the document.
54
+ # skipped) and sent to the provider as a single string. This
55
+ # directive is one-vector-per-record by design: long source text
56
+ # whose concatenation exceeds the provider's per-call token budget
57
+ # is truncated provider-side, and the stored vector represents only
58
+ # the leading portion of the document.
59
59
  #
60
- # If your source text is long-form (full articles, long
61
- # transcripts, multi-page PDFs), you have two options in v5.0:
60
+ # Chunking happens at RETRIEVAL time, not embed time. As of v5.2 the
61
+ # SDK ships {Parse::Retrieval.retrieve} and the `semantic_search`
62
+ # agent tool, which fetch the top-k whole records and split each
63
+ # record's text field into overlapping chunks for presentation
64
+ # (every chunk inherits its parent record's single score). That is
65
+ # presentation chunking — it does not change how embeddings are
66
+ # computed here.
67
+ #
68
+ # If you instead want each passage to have its OWN embedding (true
69
+ # embed-time chunking), keep one of these patterns:
62
70
  #
63
71
  # 1. Pre-chunk client-side and write each chunk as its own
64
72
  # Parse::Object record with its own `embed` declaration.
65
- # 2. Maintain a dedicated `Chunk` subclass that belongs_to the
66
- # parent record, with `embed :content, into: :embedding` on the
67
- # chunk class itself.
68
- #
69
- # A built-in chunker + `semantic_search` agent tool are scheduled
70
- # for v5.1.
73
+ # 2. Maintain a dedicated chunk subclass that belongs_to the parent
74
+ # record, with `embed :content, into: :embedding` on the chunk
75
+ # class itself.
71
76
  module EmbedManaged
72
77
  # Raised when user code tries to assign directly to a vector
73
78
  # property that's managed by an {.embed} declaration. The intent