parse-stack-next 5.7.6 → 5.8.1

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  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/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
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
@@ -269,6 +283,14 @@ module Parse
269
283
  DEFAULT_RETRIES = 2
270
284
  # The wait time in seconds between retries
271
285
  RETRY_DELAY = 1.5
286
+ # SDK-internal value for the `metadata_master:` request option. A request
287
+ # that passes it together with `use_master_key: true` reads metadata (a
288
+ # class schema, the role graph) and keeps the master key inside
289
+ # {Parse.without_master_key}. Only this exact object turns it on.
290
+ # @!visibility private
291
+ METADATA_MASTER_REQUEST = Object.new.tap do |o|
292
+ def o.inspect = "#<Parse::Client::METADATA_MASTER_REQUEST>"
293
+ end.freeze
272
294
 
273
295
  # An error when a general response error occurs when communicating with Parse server.
274
296
  class ResponseError < Parse::Error; end
@@ -446,12 +468,10 @@ module Parse
446
468
  #
447
469
  # total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
448
470
  #
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}.
471
+ # Scopes REST-routed operations (`find` / `get` / `count` / `save`) and
472
+ # mongo-direct reads (`results_direct`, `count_direct`, Atlas Search), which
473
+ # resolve the same ambient token when the query carries no explicit
474
+ # `session_token:`, `acl_user:`, or `use_master_key: true`.
455
475
  #
456
476
  # @raise [ArgumentError] if this client has no bound session token (scoping
457
477
  # would be a no-op and almost certainly a mistake).
@@ -1098,6 +1118,22 @@ module Parse
1098
1118
  @retry_limit
1099
1119
  end
1100
1120
 
1121
+ # The longest one request through {#request} can take, in seconds,
1122
+ # counting every retry attempt at its full open and read timeout plus
1123
+ # the largest retry backoff. A server-supplied Retry-After is not
1124
+ # included. Used to size locks that must be held across a request.
1125
+ # @return [Float]
1126
+ def request_time_budget
1127
+ options = @conn.respond_to?(:options) ? @conn.options : nil
1128
+ read = options.respond_to?(:timeout) ? options.timeout.to_f : 0.0
1129
+ open = options.respond_to?(:open_timeout) ? options.open_timeout.to_f : 0.0
1130
+ read = 30.0 if read <= 0
1131
+ open = 5.0 if open <= 0
1132
+ retries = [retry_limit.to_i, 0].max
1133
+ backoff = (1..retries).sum { |attempt| RETRY_DELAY * attempt * 1.25 }
1134
+ (open + read) * (retries + 1) + backoff
1135
+ end
1136
+
1101
1137
  # @return [String] the url prefix of the Parse Server url.
1102
1138
  def url_prefix
1103
1139
  @conn.url_prefix
@@ -1221,6 +1257,15 @@ module Parse
1221
1257
  _retry_count = nil
1222
1258
  _retry_delay = nil
1223
1259
  _request = nil
1260
+ # A Parse::Request carries its own per-request options (session_token,
1261
+ # use_master_key, cache, retry, ...). Merge them in before anything
1262
+ # below reads `opts`, or the auth resolution never sees them and a
1263
+ # request built for a session with `use_master_key: false` still goes
1264
+ # out with the configured master key. Options passed to this call
1265
+ # directly win over the request's own.
1266
+ if method.is_a?(Request) && method.opts.is_a?(Hash) && !method.opts.empty?
1267
+ opts = method.opts.merge(opts.is_a?(Hash) ? opts : {})
1268
+ end
1224
1269
  # Kwarg-absorption guard. The `**opts` splat in API helper methods
1225
1270
  # (lib/parse/api/*.rb) absorbs a caller-passed `opts: { ... }`
1226
1271
  # keyword as a key named `:opts` rather than as the request options
@@ -1244,10 +1289,12 @@ module Parse
1244
1289
  # attempt, turning a transient 500/503/429 into an infinite retry loop.
1245
1290
  _retry_count ||= self.retry_limit
1246
1291
 
1292
+ # `retry: false` and `retry: 0` both disable retries; a positive
1293
+ # Integer sets the budget. Anything else keeps the client default.
1247
1294
  if opts[:retry] == false
1248
1295
  _retry_count = 0
1249
- elsif opts[:retry].to_i > 0
1250
- _retry_count = opts[:retry]
1296
+ elsif opts[:retry].is_a?(Numeric)
1297
+ _retry_count = [opts[:retry].to_i, 0].max
1251
1298
  end
1252
1299
 
1253
1300
  # The effective starting budget, captured ONCE after the opts override
@@ -1259,6 +1306,15 @@ module Parse
1259
1306
  # silently disabling the backoff (every retry firing at zero delay).
1260
1307
  _retry_max ||= _retry_count
1261
1308
 
1309
+ # Work on a copy of the caller's headers. The request id header is
1310
+ # written into this hash, and it must survive the `retry` keyword (so
1311
+ # it is set above the `begin`) without leaking into a hash the caller
1312
+ # reuses for its next request.
1313
+ headers = headers ? headers.dup : {}
1314
+ # Set once a request is re-sent after an ambiguous failure, so a
1315
+ # replayed DELETE that finds the object already gone can be recognized.
1316
+ _replayed = false
1317
+
1262
1318
  begin
1263
1319
  headers ||= {}
1264
1320
  # if the first argument is a Parse::Request object, then construct it
@@ -1271,7 +1327,13 @@ module Parse
1271
1327
  body ||= _request.body
1272
1328
  headers.merge! _request.headers
1273
1329
  else
1274
- _request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts)
1330
+ # The metadata-master sentinel stays off the stored request: a
1331
+ # caller replaying `response.request` later must not inherit it.
1332
+ _request = Parse::Request.new(method, uri, body: body, headers: headers,
1333
+ opts: opts.except(:metadata_master))
1334
+ # Request copies the headers it is given, so carry its request id
1335
+ # back onto the outgoing headers.
1336
+ headers.merge!(_request.headers)
1275
1337
  end
1276
1338
 
1277
1339
  # http method
@@ -1297,6 +1359,14 @@ module Parse
1297
1359
  # never sent unless the caller explicitly passed `use_master_key: true`
1298
1360
  explicit_master = opts.key?(:use_master_key)
1299
1361
 
1362
+ # SDK metadata reads (class schemas, the role graph) keep the master
1363
+ # key inside `Parse.without_master_key`. Only the internal sentinel
1364
+ # turns this on; a `true` or any other value from a caller does not.
1365
+ # The marker goes on a per-attempt copy of the headers handed to the
1366
+ # connection (below), never on `headers` or the stored request, so a
1367
+ # retry, `response.request`, or an error reporter never carries it.
1368
+ metadata_master = opts[:metadata_master].equal?(METADATA_MASTER_REQUEST) && opts[:use_master_key] == true
1369
+
1300
1370
  if opts[:use_master_key] == false
1301
1371
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1302
1372
  elsif Parse.client_mode && opts[:use_master_key] != true
@@ -1305,7 +1375,18 @@ module Parse
1305
1375
  end
1306
1376
 
1307
1377
  raw_token = opts[:session_token]
1308
- # SEC-02: an EXPLICITLY-supplied session_token that is a blank /
1378
+ # A session token the caller put in the request headers directly
1379
+ # (`current_user(token)`, `fetch_session(token)`, `logout(token)`)
1380
+ # is as explicit as `session_token:`. Treat it as the per-call token
1381
+ # so the ambient `with_session` token or this client's bound token
1382
+ # cannot overwrite it: otherwise `User.session(token_a)` inside
1383
+ # `with_session(token_b)` resolved user B, and the identity cache
1384
+ # then mapped token A (or a garbage token) to user B for its TTL.
1385
+ if raw_token.nil?
1386
+ header_token = headers[Parse::Protocol::SESSION_TOKEN]
1387
+ raw_token = header_token if header_token.is_a?(String)
1388
+ end
1389
+ # SEC-02:an EXPLICITLY-supplied session_token that is a blank /
1309
1390
  # whitespace-only string is an unusable credential — NOT an invitation
1310
1391
  # to fall back to the master key. Treat it as "no credential"
1311
1392
  # (anonymous) and fail closed: suppress the master key and send no
@@ -1325,7 +1406,15 @@ module Parse
1325
1406
  # nested inside a `with_session(user)` block (or on a token-bound client)
1326
1407
  # would silently downgrade. The ambient wins over the bound token so a
1327
1408
  # `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)
1409
+ # Inside `Parse.with_session(nil)` (or a token-less user) the block is
1410
+ # anonymous: no ambient, no bound token, and no master key, unless
1411
+ # this call passed a token or `use_master_key: true` itself.
1412
+ anonymous_block = token.nil? && !explicit_blank_token &&
1413
+ !(explicit_master && opts[:use_master_key] == true) &&
1414
+ Parse.respond_to?(:anonymous_session?) && Parse.anonymous_session?
1415
+ if anonymous_block
1416
+ headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1417
+ elsif token.nil? && !explicit_blank_token && !(explicit_master && opts[:use_master_key] == true)
1329
1418
  ambient = Parse.current_session_token
1330
1419
  # A whitespace-only ambient must not count as present: otherwise it
1331
1420
  # blocks the bound-token fallback below and then fails the later
@@ -1336,6 +1425,7 @@ module Parse
1336
1425
  if explicit_blank_token
1337
1426
  # Fail closed: never send the master key for an unusable explicit token.
1338
1427
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
1428
+ headers.delete(Parse::Protocol::SESSION_TOKEN)
1339
1429
  elsif token.present?
1340
1430
  token = token.session_token if token.respond_to?(:session_token)
1341
1431
  headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
@@ -1346,7 +1436,12 @@ module Parse
1346
1436
  params = (method == :get ? query : body) || {}
1347
1437
  # if the path does not start with the '/1/' prefix, then add it to be nice.
1348
1438
  # actually send the request and return the body
1349
- response_env = @conn.send(method, uri, params, headers)
1439
+ send_headers = headers
1440
+ if metadata_master
1441
+ send_headers = headers.merge(Parse::Middleware::Authentication::METADATA_MASTER =>
1442
+ Parse::Middleware::Authentication::METADATA_MASTER_TOKEN)
1443
+ end
1444
+ response_env = @conn.send(method, uri, params, send_headers)
1350
1445
  response = response_env.body
1351
1446
  response.request = _request
1352
1447
 
@@ -1355,11 +1450,25 @@ module Parse
1355
1450
  Parse::Client._safe_warn("AuthenticationError", response)
1356
1451
  raise Parse::Error::AuthenticationError, response
1357
1452
  when 400, 408
1358
- if response.code == Parse::Response::ERROR_TIMEOUT || response.code == 143 #"net/http: timeout awaiting response headers"
1453
+ # Only Parse's timeout code maps here. Code 143 is Parse Server's
1454
+ # WEBHOOK_ERROR (for example "no function named: foo is defined"),
1455
+ # not a timeout.
1456
+ if response.code == Parse::Response::ERROR_TIMEOUT
1359
1457
  Parse::Client._safe_warn("TimeoutError", response)
1360
1458
  raise Parse::Error::TimeoutError, response
1361
1459
  end
1362
1460
  when 404
1461
+ if response.object_not_found? && _replayed && method == :delete
1462
+ # An earlier attempt of this DELETE failed ambiguously and was
1463
+ # re-sent. "Object not found" on the replay means the first
1464
+ # attempt removed it, so report the delete as applied.
1465
+ warn "[Parse:Retry] #{_request} : object already deleted by an earlier attempt"
1466
+ applied = Parse::Response.new({})
1467
+ applied.http_status = 200
1468
+ applied.headers = response.headers
1469
+ applied.request = _request
1470
+ return applied
1471
+ end
1363
1472
  unless response.object_not_found?
1364
1473
  Parse::Client._safe_warn("ConnectionError", response)
1365
1474
  raise Parse::Error::ConnectionError, response
@@ -1370,12 +1479,24 @@ module Parse
1370
1479
  when 429 # Request over the throttle limit
1371
1480
  Parse::Client._safe_warn("RequestLimitExceededError", response)
1372
1481
  raise Parse::Error::RequestLimitExceededError, response
1373
- when 500, 503
1482
+ when 500, 502, 503, 504
1483
+ # 502 and 504 come from a gateway or load balancer in front of
1484
+ # Parse Server. Like 500/503, the request may or may not have been
1485
+ # applied, so they are retried only when the request is idempotent.
1374
1486
  Parse::Client._safe_warn("ServiceUnavailableError", response)
1375
1487
  raise Parse::Error::ServiceUnavailableError, response
1376
1488
  end
1377
1489
 
1378
1490
  if response.error?
1491
+ if response.code == -1 && response.error.to_s.start_with?("Missing additional authData mfa")
1492
+ # Parse Server's OTHER_CAUSE (-1) for a password login on an
1493
+ # account with MFA enabled and no token supplied. It is a 400, not
1494
+ # an outage: without this branch the `<= 2` check below mapped it
1495
+ # to ServiceUnavailableError and retried the login. Surface the
1496
+ # typed error callers are documented to rescue to prompt for a code.
1497
+ Parse::Client._safe_warn("MFA::RequiredError", response)
1498
+ raise Parse::MFA::RequiredError
1499
+ end
1379
1500
  if response.code <= Parse::Response::ERROR_SERVICE_UNAVAILABLE
1380
1501
  Parse::Client._safe_warn("ServiceUnavailableError", response)
1381
1502
  raise Parse::Error::ServiceUnavailableError, response
@@ -1404,10 +1525,11 @@ module Parse
1404
1525
  # re-sending is safe for any method. 500/503 (ServiceUnavailable) is
1405
1526
  # ambiguous — a write may have applied before the error — so only
1406
1527
  # re-send when the request is idempotent (see #idempotent_retry?).
1407
- retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers)
1528
+ retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers, uri)
1408
1529
  if _retry_count > 0 && retryable
1409
- warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response.request}"
1530
+ warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response&.request || _request}"
1410
1531
  _retry_count -= 1
1532
+ _replayed = true unless e.is_a?(Parse::Error::RequestLimitExceededError)
1411
1533
  # Use Retry-After header if available, otherwise use linear backoff
1412
1534
  retry_after = response.retry_after if response.respond_to?(:retry_after)
1413
1535
  if retry_after && retry_after > 0
@@ -1443,8 +1565,9 @@ module Parse
1443
1565
  # Retrying only adds backoff latency and `[Parse:Retry]` noise
1444
1566
  # before the inevitable error, so it propagates raw and fast.
1445
1567
  raise unless connection_reset_error?(e)
1446
- if _retry_count > 0 && idempotent_retry?(method, body, headers)
1568
+ if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
1447
1569
  _retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
1570
+ _replayed = true
1448
1571
  retry
1449
1572
  end
1450
1573
  raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
@@ -1458,8 +1581,9 @@ module Parse
1458
1581
  # not `ClientError`, so it must be listed explicitly to be caught.
1459
1582
  # `Faraday::ConnectionFailed` is handled in its own rescue above,
1460
1583
  # split into retry-reset / fail-fast-refused.
1461
- if _retry_count > 0 && idempotent_retry?(method, body, headers)
1584
+ if _retry_count > 0 && idempotent_retry?(method, body, headers, uri)
1462
1585
  _retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
1586
+ _replayed = true
1463
1587
  retry
1464
1588
  end
1465
1589
  raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
@@ -1547,9 +1671,12 @@ module Parse
1547
1671
  # @param body [Hash, Object, nil] the request body.
1548
1672
  # @param headers [Hash, nil] the outgoing request headers (consulted for
1549
1673
  # the request-id header on the server-dedup fast path).
1674
+ # @param uri [String, nil] the request path, used to confirm the
1675
+ # endpoint is one Parse Server deduplicates. Without it the
1676
+ # server-dedup fast path does not apply.
1550
1677
  # @return [Boolean]
1551
- def idempotent_retry?(method, body, headers = nil)
1552
- return true if server_deduped_request?(headers)
1678
+ def idempotent_retry?(method, body, headers = nil, uri = nil)
1679
+ return true if server_deduped_request?(headers, method, uri)
1553
1680
  case method
1554
1681
  when :get, :delete then true
1555
1682
  when :put then !body_carries_atomic_op?(body)
@@ -1557,34 +1684,90 @@ module Parse
1557
1684
  end
1558
1685
  end
1559
1686
 
1687
+ # Routes on which Parse Server runs its request-id deduplication
1688
+ # (`promiseEnsureIdempotency`), by HTTP method. Paths are relative to the
1689
+ # mount point. Every other endpoint (notably `POST /batch`, files, push,
1690
+ # login, logout, schemas, config, and hooks) applies a replay again, so
1691
+ # a request to one of them is never treated as server-deduplicated.
1692
+ SERVER_DEDUP_ROUTES = {
1693
+ post: [
1694
+ %r{\Aclasses/[^/]+\z},
1695
+ %r{\Ausers\z},
1696
+ %r{\Ainstallations\z},
1697
+ %r{\Afunctions/[^/]+\z},
1698
+ %r{\Ajobs/[^/]+\z},
1699
+ ].freeze,
1700
+ put: [
1701
+ %r{\Aclasses/[^/]+/[^/]+\z},
1702
+ %r{\Ausers/[^/]+\z},
1703
+ %r{\Ainstallations/[^/]+\z},
1704
+ ].freeze,
1705
+ }.freeze
1706
+
1560
1707
  # Whether this request is covered by Parse Server's server-side request-id
1561
1708
  # 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).
1709
+ # opted in via {Parse::Request.assume_server_idempotency}, the request
1710
+ # carries a non-blank request-id header, AND it targets a route Parse
1711
+ # Server deduplicates (see {SERVER_DEDUP_ROUTES}).
1566
1712
  # @param headers [Hash, nil] the outgoing request headers.
1713
+ # @param method [Symbol, nil] the HTTP method.
1714
+ # @param uri [String, nil] the request path.
1567
1715
  # @return [Boolean]
1568
- def server_deduped_request?(headers)
1716
+ def server_deduped_request?(headers, method = nil, uri = nil)
1569
1717
  return false unless Parse::Request.assume_server_idempotency
1570
1718
  return false unless headers.is_a?(Hash)
1571
1719
  rid = headers[Parse::Request.request_id_header]
1572
- rid.is_a?(String) && !rid.strip.empty?
1720
+ return false unless rid.is_a?(String) && !rid.strip.empty?
1721
+ routes = SERVER_DEDUP_ROUTES[method.to_s.downcase.to_sym] if method
1722
+ return false if routes.nil?
1723
+ path = api_relative_path(uri)
1724
+ return false if path.nil?
1725
+ routes.any? { |route| path.match?(route) }
1573
1726
  end
1574
1727
 
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.
1728
+ # The request path relative to the Parse mount point, without a query
1729
+ # string or surrounding slashes. Accepts relative paths (`classes/Post`)
1730
+ # and absolute ones that include the mount prefix (`/parse/classes/Post`).
1731
+ # @param uri [String, nil]
1732
+ # @return [String, nil]
1733
+ def api_relative_path(uri)
1734
+ return nil if uri.nil?
1735
+ path = uri.to_s.split("?", 2).first.to_s
1736
+ prefix = url_prefix.path.to_s rescue ""
1737
+ prefix = prefix.chomp("/")
1738
+ if !prefix.empty? && path.start_with?("#{prefix}/")
1739
+ path = path.delete_prefix(prefix)
1740
+ end
1741
+ # Trim slashes with string operations rather than an end-anchored
1742
+ # regex, which backtracks polynomially on long runs of "/".
1743
+ path = path.delete_prefix("/") while path.start_with?("/")
1744
+ path = path.delete_suffix("/") while path.end_with?("/")
1745
+ path
1746
+ end
1747
+
1748
+ # Whether a request body carries a Parse operation: an `__op` key at any
1749
+ # depth, including the top level. That covers field ops (Increment, Add,
1750
+ # AddUnique, Remove, AddRelation, RemoveRelation, Delete), nested ops such
1751
+ # as a schema update's `fields: { name: { __op: "Delete" } }`, and
1752
+ # whole-body ops such as a hook delete's `{ __op: "Delete" }`. Replaying
1753
+ # any of them after the first attempt applied turns a landed write into a
1754
+ # reported failure, or applies it twice. A pre-serialized String body is
1755
+ # scanned for the literal `"__op"` key.
1583
1756
  # @param body [Object] the request body.
1584
1757
  # @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)) }
1758
+ def body_carries_atomic_op?(body, depth = 0)
1759
+ return false if depth > 32
1760
+ case body
1761
+ when Hash
1762
+ body.key?("__op") || body.key?(:__op) ||
1763
+ body.each_value.any? { |v| body_carries_atomic_op?(v, depth + 1) }
1764
+ when Array
1765
+ body.any? { |v| body_carries_atomic_op?(v, depth + 1) }
1766
+ when String
1767
+ depth.zero? && body.include?('"__op"')
1768
+ else
1769
+ false
1770
+ end
1588
1771
  end
1589
1772
 
1590
1773
  # Send a GET request.
@@ -1629,7 +1812,11 @@ module Parse
1629
1812
  # @return (see #request)
1630
1813
  def send_request(req) #Parse::Request object
1631
1814
  raise ArgumentError, "Object not of Parse::Request type." unless req.is_a?(Parse::Request)
1632
- request req.method, req.path, req.body, req.headers
1815
+ # Forward the request's own options so its session token, master-key
1816
+ # opt-out, cache directive and retry budget reach the auth resolution.
1817
+ # {#request} also merges them; passing them here keeps the contract
1818
+ # explicit at the public entry point.
1819
+ request req, opts: (req.opts || {})
1633
1820
  end
1634
1821
 
1635
1822
  # The connectable module adds methods to objects so that they can get a default