parse-stack-next 5.7.6 → 5.8.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +830 -0
  3. data/README.md +14 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +317 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +225 -8
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
@@ -133,19 +133,62 @@ module Parse
133
133
  @batch_response
134
134
  end
135
135
 
136
- # If it is a batch respnose, we'll create an array of Response objects for each
136
+ # If it is a batch response, we'll create an array of Response objects for each
137
137
  # of the ones in the batch.
138
- # @return [Array] an array of Response objects.
138
+ #
139
+ # Each entry is classified by its envelope key, not by its contents. A
140
+ # `success` entry is always a successful response, even when the saved
141
+ # object carries a column named `code` or `error` (for example one set by a
142
+ # beforeSave trigger). An `error` entry is always a failed response. An
143
+ # entry with neither key, or one that is not a Hash, is malformed and is
144
+ # reported as a failed response so the caller never treats it as a write
145
+ # that landed.
146
+ # @return [Array<Parse::Response>] an array of Response objects.
139
147
  def batch_responses
140
- return [@result] unless @batch_response
148
+ return [self] unless @batch_response
141
149
  # if batch response, generate array based on the response hash.
142
150
  @result.map do |r|
143
- next r unless r.is_a?(Hash)
144
- hash = r[SUCCESS] || r[ERROR]
145
- Parse::Response.new hash
151
+ if r.is_a?(Hash) && r.key?(SUCCESS)
152
+ Parse::Response.success_entry(r[SUCCESS])
153
+ elsif r.is_a?(Hash) && r[ERROR].is_a?(Hash)
154
+ Parse::Response.error_response(
155
+ r[ERROR][CODE] || ERROR_INTERNAL,
156
+ r[ERROR][ERROR] || "Unknown batch error",
157
+ )
158
+ elsif r.is_a?(Hash) && r.key?(ERROR)
159
+ Parse::Response.error_response(ERROR_INTERNAL, r[ERROR].to_s)
160
+ else
161
+ Parse::Response.error_response(ERROR_INTERNAL, "Malformed batch response entry")
162
+ end
146
163
  end
147
164
  end
148
165
 
166
+ # Build a successful response from a result hash. Unlike {#initialize},
167
+ # a `code` or `error` key in the hash is kept as data and never turns the
168
+ # response into a failure.
169
+ # @param result [Hash, Object] the result body.
170
+ # @return [Parse::Response]
171
+ def self.success_entry(result)
172
+ r = new(result.is_a?(Hash) ? result : {})
173
+ r.code = nil
174
+ r.error = nil
175
+ r.result = result unless result.is_a?(Hash)
176
+ r
177
+ end
178
+
179
+ # Build a failed response with the given code and message.
180
+ # @param code [Integer] the Parse error code.
181
+ # @param message [String] the error message.
182
+ # @param http_status [Integer] the HTTP status to record.
183
+ # @return [Parse::Response]
184
+ def self.error_response(code, message, http_status: 0)
185
+ r = new
186
+ r.code = code
187
+ r.error = message
188
+ r.http_status = http_status
189
+ r
190
+ end
191
+
149
192
  # This method takes the result hash and determines if it is a regular
150
193
  # parse query result, object result or a count result. The response should
151
194
  # be a hash either containing the result data or the error.
data/lib/parse/client.rb CHANGED
@@ -53,8 +53,22 @@ module Parse
53
53
  # An error when the Parse server returned invalid code.
54
54
  class ServerError < Error; end
55
55
 
56
- # An error when a Parse server responds with HTTP 500.
57
- class ServiceUnavailableError < Error; end
56
+ # An error when a Parse server (or a gateway in front of it) responds
57
+ # with HTTP 500, 502, 503, or 504.
58
+ class ServiceUnavailableError < Error
59
+ # @return [Parse::Response, nil] the response that raised the error.
60
+ attr_reader :response
61
+
62
+ def initialize(message = nil)
63
+ @response = message if message.is_a?(Parse::Response)
64
+ super
65
+ end
66
+
67
+ # @return [Integer, nil] the HTTP status of the response, when known.
68
+ def http_status
69
+ @response&.http_status&.to_i
70
+ end
71
+ end
58
72
 
59
73
  # An error when the authentication credentials in the request are invalid.
60
74
  class AuthenticationError < Error; end
@@ -446,12 +460,10 @@ module Parse
446
460
  #
447
461
  # total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
448
462
  #
449
- # Scopes REST-routed operations (`find` / `get` / `count` / `save`). It does
450
- # NOT scope mongo-direct queries (`results_direct`, `aggregate`, Atlas
451
- # search): those resolve auth from the query's own `session_token:` /
452
- # `acl_user:` and, absent that, run in MASTER mode — so a mongo-direct read
453
- # inside this block is a full master read, not anonymous. Scope mongo-direct
454
- # explicitly with a per-query `session_token:` or a scoped {Parse::Agent}.
463
+ # Scopes REST-routed operations (`find` / `get` / `count` / `save`) and
464
+ # mongo-direct reads (`results_direct`, `count_direct`, Atlas Search), which
465
+ # resolve the same ambient token when the query carries no explicit
466
+ # `session_token:`, `acl_user:`, or `use_master_key: true`.
455
467
  #
456
468
  # @raise [ArgumentError] if this client has no bound session token (scoping
457
469
  # would be a no-op and almost certainly a mistake).
@@ -1098,6 +1110,22 @@ module Parse
1098
1110
  @retry_limit
1099
1111
  end
1100
1112
 
1113
+ # The longest one request through {#request} can take, in seconds,
1114
+ # counting every retry attempt at its full open and read timeout plus
1115
+ # the largest retry backoff. A server-supplied Retry-After is not
1116
+ # included. Used to size locks that must be held across a request.
1117
+ # @return [Float]
1118
+ def request_time_budget
1119
+ options = @conn.respond_to?(:options) ? @conn.options : nil
1120
+ read = options.respond_to?(:timeout) ? options.timeout.to_f : 0.0
1121
+ open = options.respond_to?(:open_timeout) ? options.open_timeout.to_f : 0.0
1122
+ read = 30.0 if read <= 0
1123
+ open = 5.0 if open <= 0
1124
+ retries = [retry_limit.to_i, 0].max
1125
+ backoff = (1..retries).sum { |attempt| RETRY_DELAY * attempt * 1.25 }
1126
+ (open + read) * (retries + 1) + backoff
1127
+ end
1128
+
1101
1129
  # @return [String] the url prefix of the Parse Server url.
1102
1130
  def url_prefix
1103
1131
  @conn.url_prefix
@@ -1221,6 +1249,15 @@ module Parse
1221
1249
  _retry_count = nil
1222
1250
  _retry_delay = nil
1223
1251
  _request = nil
1252
+ # A Parse::Request carries its own per-request options (session_token,
1253
+ # use_master_key, cache, retry, ...). Merge them in before anything
1254
+ # below reads `opts`, or the auth resolution never sees them and a
1255
+ # request built for a session with `use_master_key: false` still goes
1256
+ # out with the configured master key. Options passed to this call
1257
+ # directly win over the request's own.
1258
+ if method.is_a?(Request) && method.opts.is_a?(Hash) && !method.opts.empty?
1259
+ opts = method.opts.merge(opts.is_a?(Hash) ? opts : {})
1260
+ end
1224
1261
  # Kwarg-absorption guard. The `**opts` splat in API helper methods
1225
1262
  # (lib/parse/api/*.rb) absorbs a caller-passed `opts: { ... }`
1226
1263
  # keyword as a key named `:opts` rather than as the request options
@@ -1244,10 +1281,12 @@ module Parse
1244
1281
  # attempt, turning a transient 500/503/429 into an infinite retry loop.
1245
1282
  _retry_count ||= self.retry_limit
1246
1283
 
1284
+ # `retry: false` and `retry: 0` both disable retries; a positive
1285
+ # Integer sets the budget. Anything else keeps the client default.
1247
1286
  if opts[:retry] == false
1248
1287
  _retry_count = 0
1249
- elsif opts[:retry].to_i > 0
1250
- _retry_count = opts[:retry]
1288
+ elsif opts[:retry].is_a?(Numeric)
1289
+ _retry_count = [opts[:retry].to_i, 0].max
1251
1290
  end
1252
1291
 
1253
1292
  # The effective starting budget, captured ONCE after the opts override
@@ -1259,6 +1298,15 @@ module Parse
1259
1298
  # silently disabling the backoff (every retry firing at zero delay).
1260
1299
  _retry_max ||= _retry_count
1261
1300
 
1301
+ # Work on a copy of the caller's headers. The request id header is
1302
+ # written into this hash, and it must survive the `retry` keyword (so
1303
+ # it is set above the `begin`) without leaking into a hash the caller
1304
+ # reuses for its next request.
1305
+ headers = headers ? headers.dup : {}
1306
+ # Set once a request is re-sent after an ambiguous failure, so a
1307
+ # replayed DELETE that finds the object already gone can be recognized.
1308
+ _replayed = false
1309
+
1262
1310
  begin
1263
1311
  headers ||= {}
1264
1312
  # if the first argument is a Parse::Request object, then construct it
@@ -1272,6 +1320,9 @@ module Parse
1272
1320
  headers.merge! _request.headers
1273
1321
  else
1274
1322
  _request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts)
1323
+ # Request copies the headers it is given, so carry its request id
1324
+ # back onto the outgoing headers.
1325
+ headers.merge!(_request.headers)
1275
1326
  end
1276
1327
 
1277
1328
  # http method
@@ -1305,7 +1356,18 @@ module Parse
1305
1356
  end
1306
1357
 
1307
1358
  raw_token = opts[:session_token]
1308
- # SEC-02: an EXPLICITLY-supplied session_token that is a blank /
1359
+ # A session token the caller put in the request headers directly
1360
+ # (`current_user(token)`, `fetch_session(token)`, `logout(token)`)
1361
+ # is as explicit as `session_token:`. Treat it as the per-call token
1362
+ # so the ambient `with_session` token or this client's bound token
1363
+ # cannot overwrite it: otherwise `User.session(token_a)` inside
1364
+ # `with_session(token_b)` resolved user B, and the identity cache
1365
+ # then mapped token A (or a garbage token) to user B for its TTL.
1366
+ if raw_token.nil?
1367
+ header_token = headers[Parse::Protocol::SESSION_TOKEN]
1368
+ raw_token = header_token if header_token.is_a?(String)
1369
+ end
1370
+ # SEC-02:an EXPLICITLY-supplied session_token that is a blank /
1309
1371
  # whitespace-only string is an unusable credential — NOT an invitation
1310
1372
  # to fall back to the master key. Treat it as "no credential"
1311
1373
  # (anonymous) and fail closed: suppress the master key and send no
@@ -1325,7 +1387,15 @@ module Parse
1325
1387
  # nested inside a `with_session(user)` block (or on a token-bound client)
1326
1388
  # would silently downgrade. The ambient wins over the bound token so a
1327
1389
  # `with_session` override inside a user-scoped client still takes effect.
1328
- if token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
1390
+ # Inside `Parse.with_session(nil)` (or a token-less user) the block is
1391
+ # anonymous: no ambient, no bound token, and no master key, unless
1392
+ # this call passed a token or `use_master_key: true` itself.
1393
+ anonymous_block = token.nil? && !explicit_blank_token &&
1394
+ !(explicit_master && opts[:use_master_key] == true) &&
1395
+ Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
1396
+ if anonymous_block
1397
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1398
+ elsif token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
1329
1399
  ambient = Parse.current_session_token
1330
1400
  # A whitespace-only ambient must not count as present: otherwise it
1331
1401
  # blocks the bound-token fallback below and then fails the later
@@ -1336,6 +1406,7 @@ module Parse
1336
1406
  if explicit_blank_token
1337
1407
  # Fail closed: never send the master key for an unusable explicit token.
1338
1408
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1409
+ headers.delete(Parse::Protocol::SESSION_TOKEN)
1339
1410
  elsif token.present?
1340
1411
  token = token.session_token if token.respond_to?(:session_token)
1341
1412
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
@@ -1355,11 +1426,25 @@ module Parse
1355
1426
  Parse::Client._safe_warn("AuthenticationError", response)
1356
1427
  raise Parse::Error::AuthenticationError, response
1357
1428
  when 400, 408
1358
- if response.code == Parse::Response::ERROR_TIMEOUT || response.code == 143 #"net/http: timeout awaiting response headers"
1429
+ # Only Parse's timeout code maps here. Code 143 is Parse Server's
1430
+ # WEBHOOK_ERROR (for example "no function named: foo is defined"),
1431
+ # not a timeout.
1432
+ if response.code == Parse::Response::ERROR_TIMEOUT
1359
1433
  Parse::Client._safe_warn("TimeoutError", response)
1360
1434
  raise Parse::Error::TimeoutError, response
1361
1435
  end
1362
1436
  when 404
1437
+ if response.object_not_found? && _replayed && method == :delete
1438
+ # An earlier attempt of this DELETE failed ambiguously and was
1439
+ # re-sent. "Object not found" on the replay means the first
1440
+ # attempt removed it, so report the delete as applied.
1441
+ warn "[Parse:Retry] #{_request} : object already deleted by an earlier attempt"
1442
+ applied = Parse::Response.new({})
1443
+ applied.http_status = 200
1444
+ applied.headers = response.headers
1445
+ applied.request = _request
1446
+ return applied
1447
+ end
1363
1448
  unless response.object_not_found?
1364
1449
  Parse::Client._safe_warn("ConnectionError", response)
1365
1450
  raise Parse::Error::ConnectionError, response
@@ -1370,12 +1455,24 @@ module Parse
1370
1455
  when 429 # Request over the throttle limit
1371
1456
  Parse::Client._safe_warn("RequestLimitExceededError", response)
1372
1457
  raise Parse::Error::RequestLimitExceededError, response
1373
- when 500, 503
1458
+ when 500, 502, 503, 504
1459
+ # 502 and 504 come from a gateway or load balancer in front of
1460
+ # Parse Server. Like 500/503, the request may or may not have been
1461
+ # applied, so they are retried only when the request is idempotent.
1374
1462
  Parse::Client._safe_warn("ServiceUnavailableError", response)
1375
1463
  raise Parse::Error::ServiceUnavailableError, response
1376
1464
  end
1377
1465
 
1378
1466
  if response.error?
1467
+ if response.code == -1 && response.error.to_s.start_with?("Missing additional authData mfa")
1468
+ # Parse Server's OTHER_CAUSE (-1) for a password login on an
1469
+ # account with MFA enabled and no token supplied. It is a 400, not
1470
+ # an outage: without this branch the `<= 2` check below mapped it
1471
+ # to ServiceUnavailableError and retried the login. Surface the
1472
+ # typed error callers are documented to rescue to prompt for a code.
1473
+ Parse::Client._safe_warn("MFA::RequiredError", response)
1474
+ raise Parse::MFA::RequiredError
1475
+ end
1379
1476
  if response.code <= Parse::Response::ERROR_SERVICE_UNAVAILABLE
1380
1477
  Parse::Client._safe_warn("ServiceUnavailableError", response)
1381
1478
  raise Parse::Error::ServiceUnavailableError, response
@@ -1404,10 +1501,11 @@ module Parse
1404
1501
  # re-sending is safe for any method. 500/503 (ServiceUnavailable) is
1405
1502
  # ambiguous — a write may have applied before the error — so only
1406
1503
  # re-send when the request is idempotent (see #idempotent_retry?).
1407
- retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers)
1504
+ retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers, uri)
1408
1505
  if _retry_count > 0 && retryable
1409
- warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response.request}"
1506
+ warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response&.request || _request}"
1410
1507
  _retry_count -= 1
1508
+ _replayed = true unless e.is_a?(Parse::Error::RequestLimitExceededError)
1411
1509
  # Use Retry-After header if available, otherwise use linear backoff
1412
1510
  retry_after = response.retry_after if response.respond_to?(:retry_after)
1413
1511
  if retry_after && retry_after > 0
@@ -1443,8 +1541,9 @@ module Parse
1443
1541
  # Retrying only adds backoff latency and `[Parse:Retry]` noise
1444
1542
  # before the inevitable error, so it propagates raw and fast.
1445
1543
  raise unless connection_reset_error?(e)
1446
- if _retry_count > 0 && idempotent_retry?(method, body, headers)
1544
+ if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
1447
1545
  _retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
1546
+ _replayed = true
1448
1547
  retry
1449
1548
  end
1450
1549
  raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
@@ -1458,8 +1557,9 @@ module Parse
1458
1557
  # not `ClientError`, so it must be listed explicitly to be caught.
1459
1558
  # `Faraday::ConnectionFailed` is handled in its own rescue above,
1460
1559
  # split into retry-reset / fail-fast-refused.
1461
- if _retry_count > 0 && idempotent_retry?(method, body, headers)
1560
+ if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
1462
1561
  _retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
1562
+ _replayed = true
1463
1563
  retry
1464
1564
  end
1465
1565
  raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
@@ -1547,9 +1647,12 @@ module Parse
1547
1647
  # @param body [Hash, Object, nil] the request body.
1548
1648
  # @param headers [Hash, nil] the outgoing request headers (consulted for
1549
1649
  # the request-id header on the server-dedup fast path).
1650
+ # @param uri [String, nil] the request path, used to confirm the
1651
+ # endpoint is one Parse Server deduplicates. Without it the
1652
+ # server-dedup fast path does not apply.
1550
1653
  # @return [Boolean]
1551
- def idempotent_retry?(method, body, headers = nil)
1552
- return true if server_deduped_request?(headers)
1654
+ def idempotent_retry?(method, body, headers = nil, uri = nil)
1655
+ return true if server_deduped_request?(headers, method, uri)
1553
1656
  case method
1554
1657
  when :get, :delete then true
1555
1658
  when :put then !body_carries_atomic_op?(body)
@@ -1557,34 +1660,90 @@ module Parse
1557
1660
  end
1558
1661
  end
1559
1662
 
1663
+ # Routes on which Parse Server runs its request-id deduplication
1664
+ # (`promiseEnsureIdempotency`), by HTTP method. Paths are relative to the
1665
+ # mount point. Every other endpoint (notably `POST /batch`, files, push,
1666
+ # login, logout, schemas, config, and hooks) applies a replay again, so
1667
+ # a request to one of them is never treated as server-deduplicated.
1668
+ SERVER_DEDUP_ROUTES = {
1669
+ post: [
1670
+ %r{\Aclasses/[^/]+\z},
1671
+ %r{\Ausers\z},
1672
+ %r{\Ainstallations\z},
1673
+ %r{\Afunctions/[^/]+\z},
1674
+ %r{\Ajobs/[^/]+\z},
1675
+ ].freeze,
1676
+ put: [
1677
+ %r{\Aclasses/[^/]+/[^/]+\z},
1678
+ %r{\Ausers/[^/]+\z},
1679
+ %r{\Ainstallations/[^/]+\z},
1680
+ ].freeze,
1681
+ }.freeze
1682
+
1560
1683
  # Whether this request is covered by Parse Server's server-side request-id
1561
1684
  # deduplication, making a replay a no-op. True only when the operator has
1562
- # opted in via {Parse::Request.assume_server_idempotency} AND the request
1563
- # actually carries a non-blank request-id header (writes to inherently
1564
- # non-idempotent paths — sessions, logout, functions, push, jobs — never
1565
- # get a request id, so they correctly fail this check).
1685
+ # opted in via {Parse::Request.assume_server_idempotency}, the request
1686
+ # carries a non-blank request-id header, AND it targets a route Parse
1687
+ # Server deduplicates (see {SERVER_DEDUP_ROUTES}).
1566
1688
  # @param headers [Hash, nil] the outgoing request headers.
1689
+ # @param method [Symbol, nil] the HTTP method.
1690
+ # @param uri [String, nil] the request path.
1567
1691
  # @return [Boolean]
1568
- def server_deduped_request?(headers)
1692
+ def server_deduped_request?(headers, method = nil, uri = nil)
1569
1693
  return false unless Parse::Request.assume_server_idempotency
1570
1694
  return false unless headers.is_a?(Hash)
1571
1695
  rid = headers[Parse::Request.request_id_header]
1572
- rid.is_a?(String) && !rid.strip.empty?
1696
+ return false unless rid.is_a?(String) && !rid.strip.empty?
1697
+ routes = SERVER_DEDUP_ROUTES[method.to_s.downcase.to_sym] if method
1698
+ return false if routes.nil?
1699
+ path = api_relative_path(uri)
1700
+ return false if path.nil?
1701
+ routes.any? { |route| path.match?(route) }
1573
1702
  end
1574
1703
 
1575
- # Whether a request body carries a Parse atomic operation, i.e. any field
1576
- # whose value is a Hash with an `__op` key (Increment, Add, AddUnique,
1577
- # Remove, AddRelation, RemoveRelation, Delete). Such ops are not idempotent
1578
- # and must not be replayed on an ambiguous failure. Assumes the body is a
1579
- # Ruby Hash, which the SDK's normal save/update path always provides; a
1580
- # pre-serialized String body is treated as op-free (and therefore
1581
- # retryable), so callers handing `request` a raw JSON string for a
1582
- # PUT-with-op would bypass this guard.
1704
+ # The request path relative to the Parse mount point, without a query
1705
+ # string or surrounding slashes. Accepts relative paths (`classes/Post`)
1706
+ # and absolute ones that include the mount prefix (`/parse/classes/Post`).
1707
+ # @param uri [String, nil]
1708
+ # @return [String, nil]
1709
+ def api_relative_path(uri)
1710
+ return nil if uri.nil?
1711
+ path = uri.to_s.split("?", 2).first.to_s
1712
+ prefix = url_prefix.path.to_s rescue ""
1713
+ prefix = prefix.chomp("/")
1714
+ if !prefix.empty? && path.start_with?("#{prefix}/")
1715
+ path = path.delete_prefix(prefix)
1716
+ end
1717
+ # Trim slashes with string operations rather than an end-anchored
1718
+ # regex, which backtracks polynomially on long runs of "/".
1719
+ path = path.delete_prefix("/") while path.start_with?("/")
1720
+ path = path.delete_suffix("/") while path.end_with?("/")
1721
+ path
1722
+ end
1723
+
1724
+ # Whether a request body carries a Parse operation: an `__op` key at any
1725
+ # depth, including the top level. That covers field ops (Increment, Add,
1726
+ # AddUnique, Remove, AddRelation, RemoveRelation, Delete), nested ops such
1727
+ # as a schema update's `fields: { name: { __op: "Delete" } }`, and
1728
+ # whole-body ops such as a hook delete's `{ __op: "Delete" }`. Replaying
1729
+ # any of them after the first attempt applied turns a landed write into a
1730
+ # reported failure, or applies it twice. A pre-serialized String body is
1731
+ # scanned for the literal `"__op"` key.
1583
1732
  # @param body [Object] the request body.
1584
1733
  # @return [Boolean]
1585
- def body_carries_atomic_op?(body)
1586
- return false unless body.is_a?(Hash)
1587
- body.any? { |_k, v| v.is_a?(Hash) && (v.key?("__op") || v.key?(:__op)) }
1734
+ def body_carries_atomic_op?(body, depth = 0)
1735
+ return false if depth > 32
1736
+ case body
1737
+ when Hash
1738
+ body.key?("__op") || body.key?(:__op) ||
1739
+ body.each_value.any? { |v| body_carries_atomic_op?(v, depth + 1) }
1740
+ when Array
1741
+ body.any? { |v| body_carries_atomic_op?(v, depth + 1) }
1742
+ when String
1743
+ depth.zero? && body.include?('"__op"')
1744
+ else
1745
+ false
1746
+ end
1588
1747
  end
1589
1748
 
1590
1749
  # Send a GET request.
@@ -1629,7 +1788,11 @@ module Parse
1629
1788
  # @return (see #request)
1630
1789
  def send_request(req) #Parse::Request object
1631
1790
  raise ArgumentError, "Object not of Parse::Request type." unless req.is_a?(Parse::Request)
1632
- request req.method, req.path, req.body, req.headers
1791
+ # Forward the request's own options so its session token, master-key
1792
+ # opt-out, cache directive and retry budget reach the auth resolution.
1793
+ # {#request} also merges them; passing them here keeps the contract
1794
+ # explicit at the public entry point.
1795
+ request req, opts: (req.opts || {})
1633
1796
  end
1634
1797
 
1635
1798
  # The connectable module adds methods to objects so that they can get a default