parse-stack-next 5.7.5 → 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 +856 -0
  3. data/README.md +15 -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 +190 -14
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +318 -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 +290 -17
  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
@@ -346,15 +346,36 @@ module Parse
346
346
  puts "[[Response]] --------------------------------------\n"
347
347
  end
348
348
 
349
+ parsed = true
349
350
  begin
350
351
  r = Parse::Response.new(response_env.body)
351
352
  rescue => e
353
+ parsed = false
352
354
  r = Parse::Response.new
353
355
  r.code = response_env.status
354
356
  r.error = "Invalid response for #{env[:method]} #{env[:url]}: #{e}"
355
357
  end
358
+ status = response_env[:status].to_i
356
359
  r.http_status = response_env[:status]
357
360
  r.headers = response_env[:response_headers]
361
+ if parsed && status.between?(200, 299)
362
+ # Parse Server never reports an error with a 2xx status, so a
363
+ # `code` or `error` key in a 2xx body is object data (for example
364
+ # a column set by a beforeSave trigger), not an error envelope.
365
+ r.code = nil
366
+ r.error = nil
367
+ elsif status > 0 && !status.between?(200, 299) && r.error.blank?
368
+ # A non-2xx response is a failure even without a Parse `error`
369
+ # field. It may come from something in front of Parse Server (an
370
+ # API gateway or load balancer returning 502/504 with
371
+ # `{"message": ...}`), or from Parse Server itself (a failed
372
+ # transaction returns `{"code": 1, "message": ...}`). Record the
373
+ # HTTP status as the code when there is none, so the request is
374
+ # never mistaken for a success.
375
+ detail = r.result.is_a?(Hash) ? r.result["message"] : nil
376
+ r.code ||= status
377
+ r.error = detail.present? ? detail.to_s : "HTTP #{status} response without a Parse error body"
378
+ end
358
379
  r.code ||= response_env[:status] if r.error.present?
359
380
  response_env[:body] = r
360
381
  end
@@ -5,6 +5,8 @@ require "faraday"
5
5
  require "moneta"
6
6
  require "connection_pool"
7
7
  require "digest"
8
+ require "securerandom"
9
+ require "json"
8
10
  require_relative "protocol"
9
11
 
10
12
  module Parse
@@ -38,6 +40,29 @@ module Parse
38
40
  CACHE_EXPIRES_DURATION = "X-Parse-Stack-Cache-Expires"
39
41
  # Header in request to enable write-only cache mode (skip read, still write)
40
42
  CACHE_WRITE_ONLY = "X-Parse-Stack-Cache-Write-Only"
43
+ # Paths whose responses are never cached. `users/me` and `sessions/me`
44
+ # answer "who owns this token", so a cached copy keeps a logged-out or
45
+ # revoked token resolving to its user until the entry expires. The
46
+ # credential endpoints carry a password and must never be stored.
47
+ UNCACHEABLE_PATH_RE = %r{/(?:users/me|sessions/me|login|verifyPassword|logout)/?\z}.freeze
48
+ # Prefix of the per-resource version keys. See {#version_keys}.
49
+ LEGACY_VERSION_PREFIX = "rv"
50
+ # Prefix of the per-class version keys folded into collection reads
51
+ # (queries, aggregates). See {#version_keys}.
52
+ CLASS_VERSION_PREFIX = "cv"
53
+ # Request header BodyBuilder sets when it re-sends a long GET as a POST.
54
+ METHOD_OVERRIDE = "X-Http-Method-Override"
55
+ # Paths that name a Parse class, optionally followed by an object id.
56
+ CLASS_PATH_RE = %r{(?:\A|/)(?:classes|aggregate|purge|schemas)/([^/]+)(?:/([^/]+))?/?\z}.freeze
57
+ # Built-in class endpoints, optionally followed by an object id.
58
+ SYSTEM_CLASS_PATH_RE = %r{(?:\A|/)(users|roles|installations|sessions)(?:/([^/]+))?/?\z}.freeze
59
+ # Class names for the built-in class endpoints.
60
+ SYSTEM_CLASSES = {
61
+ "users" => "_User", "roles" => "_Role",
62
+ "installations" => "_Installation", "sessions" => "_Session",
63
+ }.freeze
64
+ # Path of the batch endpoint, whose body names the resources it writes.
65
+ BATCH_PATH_RE = %r{(?:\A|/)batch/?\z}.freeze
41
66
 
42
67
  class << self
43
68
  # @!attribute enabled
@@ -150,6 +175,17 @@ module Parse
150
175
 
151
176
  url = env.url
152
177
  method = env.method
178
+
179
+ # Identity and credential endpoints are passthrough: never read, never
180
+ # stored. See UNCACHEABLE_PATH_RE.
181
+ return @app.call(env) if url.path.to_s.match?(UNCACHEABLE_PATH_RE)
182
+
183
+ # A long query that BodyBuilder re-sent as a POST with a GET method
184
+ # override is a read. It is never cached (only GETs are), and treating
185
+ # it as a write would retire every cached query of its class each
186
+ # time a long query ran.
187
+ return @app.call(env) if method != :get && @request_headers[METHOD_OVERRIDE].to_s.casecmp?("GET")
188
+
153
189
  @cache_key = url.to_s
154
190
 
155
191
  # Auth discriminator. A master-key request bypasses ACL, CLP and
@@ -158,16 +194,33 @@ module Parse
158
194
  # protectedFields entity rules and row ACLs. These must never share a
159
195
  # cache entry or the cache would hand privileged fields to an
160
196
  # unprivileged caller.
197
+ #
198
+ # Both layouts also bind every key to the application id and the
199
+ # credential that produced it (`@legacy_auth`, see {#versioned_key}).
200
+ # Without that, a client configured with a different application id
201
+ # or a wrong master/REST key, sharing the same store, was served
202
+ # another application's cached private rows: the URL alone matched.
203
+ # `@old_shape_key` is the pre-credential key shape, kept only so a
204
+ # write still evicts entries written by older SDK versions during a
205
+ # rolling deploy.
161
206
  @cache_auth = :anon
207
+ app_id = @request_headers[APP_ID].to_s
162
208
  if @request_headers.key?(SESSION_TOKEN)
163
209
  @session_token = @request_headers[SESSION_TOKEN]
164
210
  hashed_token = Digest::SHA256.hexdigest(@session_token.to_s)[0, 32]
165
211
  @cache_auth = hashed_token
166
- @cache_key = "#{hashed_token}:#{@cache_key}" # prefix with hashed token
212
+ @old_shape_key = "#{hashed_token}:#{@cache_key}" # prefix with hashed token
213
+ @legacy_auth = "s:#{credential_digest(app_id, @session_token)}"
167
214
  elsif @request_headers.key?(MASTER_KEY)
168
215
  @cache_auth = :master
169
- @cache_key = "mk:#{@cache_key}" # prefix for master key requests
216
+ @old_shape_key = "mk:#{@cache_key}" # prefix for master key requests
217
+ @legacy_auth = "mk:#{credential_digest(app_id, @request_headers[MASTER_KEY])}"
218
+ else
219
+ @old_shape_key = @cache_key
220
+ @legacy_auth = "an:#{credential_digest(app_id, @request_headers[API_KEY])}"
170
221
  end
222
+ @cache_key = "#{@legacy_auth}:#{@cache_key}"
223
+ @app_id = app_id
171
224
 
172
225
  # Optional ambient cache-tenant scope from `Parse.with_cache_tenant`.
173
226
  # When present, composes between the configured namespace and the
@@ -180,22 +233,43 @@ module Parse
180
233
  # tenant feature don't accidentally re-hydrate into a tenanted
181
234
  # request and vice versa.
182
235
  @cache_tenant = Parse.respond_to?(:current_cache_tenant) ? Parse.current_cache_tenant : nil
183
- @cache_key = "T:#{@cache_tenant}:#{@cache_key}" if @cache_tenant
236
+ if @cache_tenant
237
+ @cache_key = "T:#{@cache_tenant}:#{@cache_key}"
238
+ @old_shape_key = "T:#{@cache_tenant}:#{@old_shape_key}"
239
+ end
184
240
 
185
241
  # Namespace outermost so a SCAN over `<namespace>:*` evicts a whole
186
242
  # tenant/app cleanly without touching another app's entries.
187
- @cache_key = "#{@namespace}:#{@cache_key}" if @namespace
243
+ if @namespace
244
+ @cache_key = "#{@namespace}:#{@cache_key}"
245
+ @old_shape_key = "#{@namespace}:#{@old_shape_key}"
246
+ end
188
247
 
189
- # Keep the legacy key for dual-delete on invalidation, then switch the
190
- # live key to the keyspace form when one is configured.
248
+ # Keep the legacy key base for versioning below. The live key is
249
+ # built by {#versioned_key} once the versions are known, in the
250
+ # keyspace form when one is configured.
191
251
  @legacy_cache_key = @cache_key
192
- if @keyspace
193
- @cache_key = @keyspace.cache_key(url, auth: @cache_auth, tenant: @cache_tenant)
194
- end
252
+ @cache_key = nil
195
253
 
196
254
  url_path = url.path
197
255
 
198
256
  begin
257
+ # Resolve the current versions and fold them into the key, in both
258
+ # layouts. The resource version retires every credential variant
259
+ # of a resource on a write to it; collection reads also carry the
260
+ # class version, which any write to the class replaces (see
261
+ # {#version_keys}).
262
+ #
263
+ # A read establishes its versions BEFORE the request goes out,
264
+ # creating any that are missing, and later stores its response
265
+ # only under those. Resolving them after the response instead let
266
+ # a read that started before a write but finished after it adopt
267
+ # the versions the write had just created and store its older
268
+ # body under them, so a revoked row was served to every later
269
+ # reader. A write only reads them: `nil` means a version does not
270
+ # exist yet, so no entry bound to it can be live.
271
+ @versions = method == :get ? establish_versions(url) : read_versions(url)
272
+ @cache_key = @versions ? versioned_key(url, @versions) : nil
199
273
  # Skip cache read if write_only mode is enabled
200
274
  if method == :get && @cache_key.present? && !@write_only && @store.key?(@cache_key)
201
275
  # Debug-log the URL **path only** — `url.to_s` would include the
@@ -241,7 +315,7 @@ module Parse
241
315
  delete_cache_variants(url)
242
316
  instrument_cache(:miss, method: method, url_path: url_path, reason: :empty_payload)
243
317
  end
244
- elsif method == :get && @cache_key.present? && !@write_only
318
+ elsif method == :get && !@write_only
245
319
  # GET miss: opportunistically clear any sibling variants of the
246
320
  # current namespace (anonymous `<url>` and master-key `mk:<url>`
247
321
  # under the same namespace) so a stale variant from a prior
@@ -255,13 +329,14 @@ module Parse
255
329
  # should evict those once at upgrade time via SCAN.
256
330
  delete_cache_variants(url)
257
331
  instrument_cache(:miss, method: method, url_path: url_path)
258
- elsif method == :get && @cache_key.present? && @write_only
332
+ elsif method == :get && @write_only
259
333
  delete_cache_variants(url)
260
334
  instrument_cache(:miss, method: method, url_path: url_path, reason: :write_only)
261
- elsif @cache_key.present?
335
+ elsif method != :get
262
336
  #non GET requets should clear the cache for that same resource path.
263
337
  #ex. a POST to /1/classes/Artist/<objectId> should delete the cache for a GET
264
338
  # request for the same '/1/classes/Artist/<objectId>' where objectId are equivalent
339
+ @write_targets = write_targets(env, url)
265
340
  delete_cache_variants(url, resource: true)
266
341
  instrument_cache(:delete, method: method, url_path: url_path)
267
342
  end
@@ -271,8 +346,7 @@ module Parse
271
346
  # under Redis Cluster. Without it those escape the middleware and turn a
272
347
  # cache problem into a failed application request, which inverts the
273
348
  # whole point of the cache being optional.
274
- rescue ::TypeError, Errno::EINVAL, Redis::CannotConnectError, Redis::TimeoutError,
275
- Redis::CommandError, ConnectionPool::TimeoutError => e
349
+ rescue *cache_store_errors => e
276
350
  # if the cache store fails to connect, catch the exception but proceed
277
351
  # with the regular request, but turn off caching for this request. It is possible
278
352
  # that the cache connection resumes at a later point, so this is temporary.
@@ -289,27 +363,78 @@ module Parse
289
363
  response_env.body.present? && response_env.response_headers[CONTENT_LENGTH_KEY].to_i.between?(20, 1_250_000)
290
364
  store_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
291
365
  begin
292
- # Store with string keys (and a plain Hash of headers) so the
293
- # value round-trips losslessly through the Redis cache wrapper's
294
- # JSON serialization. The read path above reads string keys first
295
- # with a symbol-key fallback for legacy entries.
296
- @store.store(@cache_key,
297
- { "headers" => response_env.response_headers.to_h, "body" => response_env.body },
298
- expires: @expires)
299
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - store_start) * 1000.0).round(3)
300
- instrument_cache(:store, method: method, url_path: url_path, duration_ms: duration_ms)
366
+ # Store only under the versions established before dispatch,
367
+ # and only if none of them changed while the request was in
368
+ # flight. A change means a write landed during the request, so
369
+ # this body may predate it (a row whose ACL was just revoked,
370
+ # for instance) and must not be cached under any version.
371
+ if @versions && @cache_key && read_versions(url) == @versions
372
+ # Store with string keys (and a plain Hash of headers) so the
373
+ # value round-trips losslessly through the Redis cache
374
+ # wrapper's JSON serialization. The read path above reads
375
+ # string keys first with a symbol-key fallback for legacy
376
+ # entries.
377
+ @store.store(@cache_key,
378
+ { "headers" => response_env.response_headers.to_h, "body" => response_env.body },
379
+ expires: @expires)
380
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - store_start) * 1000.0).round(3)
381
+ instrument_cache(:store, method: method, url_path: url_path, duration_ms: duration_ms)
382
+ elsif self.class.logging.present?
383
+ puts("[Parse::Cache] Skip store, version changed in flight >> #{url_path}")
384
+ end
301
385
  rescue => e
302
386
  puts "[Parse::Cache] Store Error: #{e.class.name}"
303
387
  instrument_cache(:error, method: method, url_path: url_path, error: e.class.name)
304
388
  end
305
389
  end # if
306
- # do something with the response
307
- # response_env[:response_headers].merge!(...)
390
+
391
+ # Retire the written resources again once the server has applied
392
+ # the write. A reader that established the versions the pre-write
393
+ # bump created, and then fetched the pre-write state, would
394
+ # otherwise cache it under live versions; a row whose ACL was just
395
+ # revoked would then stay readable until the entry expired. The
396
+ # second bump makes that reader's versions stale, so its in-flight
397
+ # check skips the store, or its stored entry becomes unreachable.
398
+ if @write_targets
399
+ begin
400
+ bump_versions(url, @write_targets)
401
+ rescue => e
402
+ puts "[Parse::Cache] Error: #{e.class.name}"
403
+ instrument_cache(:error, method: method, url_path: url_path, error: e.class.name)
404
+ end
405
+ end
308
406
  end
309
407
  end
310
408
 
311
409
  private
312
410
 
411
+ # Store errors that disable caching for the request rather than fail
412
+ # it. The Redis and connection_pool classes are listed only when those
413
+ # libraries are loaded: naming an unloaded constant in a `rescue`
414
+ # raises NameError while the rescue is evaluated, so a TypeError from a
415
+ # memory or other non-Redis store would escape as
416
+ # "uninitialized constant Redis" instead of falling back.
417
+ #
418
+ # @return [Array<Class>]
419
+ def cache_store_errors
420
+ errors = [::TypeError, Errno::EINVAL, Errno::ECONNREFUSED]
421
+ if defined?(::Redis)
422
+ # In redis-rb 5, CannotConnectError, TimeoutError and
423
+ # ConnectionError are siblings under BaseConnectionError, and
424
+ # ReadOnlyError (a replica after failover) sits under CommandError.
425
+ # Each name is checked on its own because older releases lack some.
426
+ %w[BaseConnectionError CannotConnectError TimeoutError ConnectionError
427
+ CommandError ReadOnlyError].each do |name|
428
+ errors << ::Redis.const_get(name) if ::Redis.const_defined?(name, false)
429
+ end
430
+ end
431
+ # redis-rb 5 is built on redis-client, whose errors can surface
432
+ # unwrapped from some code paths.
433
+ errors << ::RedisClient::Error if defined?(::RedisClient::Error)
434
+ errors << ::ConnectionPool::TimeoutError if defined?(::ConnectionPool::TimeoutError)
435
+ errors.uniq
436
+ end
437
+
313
438
  # Emit an ActiveSupport::Notifications event under the `parse.cache.*`
314
439
  # namespace.
315
440
  #
@@ -360,7 +485,220 @@ module Parse
360
485
  def delete_cache_variants(url, resource: false)
361
486
  delete_keyspace_variants(url) if resource && @keyspace
362
487
  delete_legacy_variants(url) if legacy_variants?
363
- @store.delete @cache_key # final key
488
+ @store.delete @cache_key if @cache_key # final key
489
+ # A write replaces the versions of everything it touched, which
490
+ # retires every credential variant in either layout: other sessions'
491
+ # entries included, which a delete cannot name. This is what stops a
492
+ # user whose read access was just revoked from reading the cached
493
+ # copy, directly or through a cached query over the class.
494
+ bump_versions(url, @write_targets || [url.path]) if resource
495
+ end
496
+
497
+ # Digest binding a legacy cache key to the application and the
498
+ # credential that authorized the request. Truncated SHA-256: the key is
499
+ # never a credential, only a discriminator.
500
+ # @!visibility private
501
+ def credential_digest(app_id, credential)
502
+ Digest::SHA256.hexdigest("#{app_id}\x00#{credential}")[0, 32]
503
+ end
504
+
505
+ # Version keys a read of this URL is bound to: the resource version
506
+ # for its path and, for a collection read (a query, an aggregate, or a
507
+ # list of a built-in class), the version of the class.
508
+ #
509
+ # The resource version is keyed by application and URL path (no query
510
+ # string), and NOT by credential or tenant, so a write through any
511
+ # caller retires the entries of every caller. Using the path means a
512
+ # write to `classes/Post/abc` also retires `classes/Post/abc?include=...`.
513
+ #
514
+ # The class version is what retires cached queries. A query result for
515
+ # `classes/Post?where=...` can contain any Post, so a write to one
516
+ # (an update, an ACL change, a delete, a create, or a batch touching
517
+ # the class) replaces the class version and every cached Post query
518
+ # becomes unreachable. Single-object reads do not carry it, so a write
519
+ # to one object leaves other objects' cached reads alone.
520
+ # @!visibility private
521
+ # @return [Array<String>]
522
+ def version_keys(url)
523
+ keys = [version_key(LEGACY_VERSION_PREFIX, "#{url_origin(url)}#{url.path}")]
524
+ class_name, object_id = class_scope(url.path)
525
+ if class_name && object_id.nil?
526
+ keys << version_key(CLASS_VERSION_PREFIX, "#{url_origin(url)}\x00#{class_name}")
527
+ end
528
+ keys
529
+ end
530
+
531
+ # Store key for one version. Truncated SHA-256 of the application id
532
+ # and the material, so no path or class name appears in the key. In
533
+ # the keyspace layout it sits in the cache family, outside any tenant,
534
+ # so a write under one tenant retires every tenant's entries and a
535
+ # whole-keyspace clear still removes it.
536
+ # @!visibility private
537
+ def version_key(prefix, material)
538
+ digest = Digest::SHA256.hexdigest("#{@app_id}\x00#{material}")[0, 32]
539
+ return "#{@keyspace.family_prefix(:cache)}:#{prefix}:#{digest}" if @keyspace
540
+ key = "#{prefix}:#{digest}"
541
+ @namespace ? "#{@namespace}:#{key}" : key
542
+ end
543
+
544
+ # @!visibility private
545
+ def url_origin(url)
546
+ "#{url.scheme}://#{url.host}:#{url.port}"
547
+ end
548
+
549
+ # The class a path addresses and the object id, if any.
550
+ # @!visibility private
551
+ # @return [Array(String, String), nil] `[class_name, object_id_or_nil]`.
552
+ def class_scope(path)
553
+ path = path.to_s
554
+ if (m = path.match(CLASS_PATH_RE))
555
+ [m[1], m[2]]
556
+ elsif (m = path.match(SYSTEM_CLASS_PATH_RE))
557
+ [SYSTEM_CLASSES[m[1]], m[2]]
558
+ end
559
+ end
560
+
561
+ # @!visibility private
562
+ # @return [Array<String>, nil] the current versions, or nil when any
563
+ # of them does not exist yet.
564
+ def read_versions(url)
565
+ versions = version_keys(url).map { |key| read_version(key) }
566
+ versions.include?(nil) ? nil : versions
567
+ end
568
+
569
+ # The current versions, creating any that do not exist yet. Called
570
+ # before a read is dispatched, so the read is bound to versions that
571
+ # exist before the server answers it.
572
+ # @!visibility private
573
+ # @return [Array<String>]
574
+ def establish_versions(url)
575
+ version_keys(url).map { |key| establish_version(key) }
576
+ end
577
+
578
+ # The current value of one version key, creating it when missing.
579
+ #
580
+ # Creation must not overwrite a version a concurrent write just
581
+ # bumped: the reader would then hold a version the write never
582
+ # retires. Where the store has an atomic set-if-absent (`create`),
583
+ # a lost race simply adopts the winner's value. Otherwise the value
584
+ # is written and read back, and whatever the store then holds is the
585
+ # version used. A write that landed in between still makes its
586
+ # post-response bump, which the in-flight check in {#call!} detects.
587
+ # @!visibility private
588
+ # @return [String]
589
+ def establish_version(key)
590
+ current = read_version(key)
591
+ return current if current
592
+ version = SecureRandom.hex(8)
593
+ if store_creates?
594
+ begin
595
+ return version if @store.create(key, version, expires: legacy_version_ttl)
596
+ current = read_version(key)
597
+ return current if current
598
+ rescue NotImplementedError
599
+ # Fall through to a plain write and read-back.
600
+ end
601
+ end
602
+ @store.store(key, version, expires: legacy_version_ttl)
603
+ read_version(key) || version
604
+ end
605
+
606
+ # @!visibility private
607
+ # @return [String, nil]
608
+ def read_version(key)
609
+ value = @store[key]
610
+ value.is_a?(String) && !value.empty? ? value : nil
611
+ end
612
+
613
+ # Whether the store offers an atomic set-if-absent.
614
+ # @!visibility private
615
+ def store_creates?
616
+ return false unless @store.respond_to?(:create)
617
+ return true unless @store.respond_to?(:supports?)
618
+ !!@store.supports?(:create)
619
+ end
620
+
621
+ # Replace a version with a fresh random one. A random value rather
622
+ # than a counter: if the version key expires and is recreated, a
623
+ # counter could return to a value that older entries still carry and
624
+ # bring them back. A fresh nonce never matches an old entry.
625
+ # @!visibility private
626
+ # @return [String] the new version.
627
+ def write_version(key)
628
+ version = SecureRandom.hex(8)
629
+ @store.store(key, version, expires: legacy_version_ttl)
630
+ version
631
+ end
632
+
633
+ # Replace the resource version of every written path and the class
634
+ # version of every class those paths belong to.
635
+ # @!visibility private
636
+ def bump_versions(url, paths)
637
+ origin = url_origin(url)
638
+ keys = []
639
+ paths.each do |path|
640
+ keys << version_key(LEGACY_VERSION_PREFIX, "#{origin}#{path}")
641
+ class_name, = class_scope(path)
642
+ keys << version_key(CLASS_VERSION_PREFIX, "#{origin}\x00#{class_name}") if class_name
643
+ end
644
+ keys.uniq.each { |key| write_version(key) }
645
+ end
646
+
647
+ # Paths a write touches: the request path, plus every non-GET sub-request
648
+ # of a batch. A batch body that cannot be read contributes nothing
649
+ # beyond the batch path itself.
650
+ # @!visibility private
651
+ # @return [Array<String>]
652
+ def write_targets(env, url)
653
+ targets = [url.path]
654
+ return targets unless url.path.to_s.match?(BATCH_PATH_RE)
655
+ mount = url.path.to_s.sub(BATCH_PATH_RE, "")
656
+ batch_requests(env.body).each do |sub|
657
+ next unless sub.is_a?(Hash)
658
+ next if (sub["method"] || sub[:method]).to_s.casecmp?("GET")
659
+ path = (sub["path"] || sub[:path]).to_s
660
+ next if path.empty?
661
+ path = path.start_with?("/") ? path : "#{mount}/#{path}"
662
+ targets << path.split("?", 2).first
663
+ end
664
+ targets.uniq
665
+ end
666
+
667
+ # @!visibility private
668
+ def batch_requests(body)
669
+ body = JSON.parse(body) if body.is_a?(String)
670
+ list = body.is_a?(Hash) ? (body["requests"] || body[:requests]) : nil
671
+ list.is_a?(Array) ? list : []
672
+ rescue JSON::ParserError
673
+ []
674
+ end
675
+
676
+ # The version key only needs to outlive the entries bound to it. When it
677
+ # expires first, those entries become unreachable (a miss), never stale.
678
+ # @!visibility private
679
+ def legacy_version_ttl
680
+ [@expires.to_i * 2, 60].max
681
+ end
682
+
683
+ # The live cache key for a read bound to `versions`. Both layouts bind
684
+ # the key to the credential that produced the body (`@legacy_auth`: a
685
+ # digest of the application id with the session token, master key or
686
+ # REST key actually sent), so a wrong master key or another REST key
687
+ # cannot read an entry cached under the right one.
688
+ #
689
+ # In the keyspace layout the credential digest and the versions are
690
+ # folded into the auth segment, after the URL digest, so
691
+ # {Parse::Cache::Keyspace#resource_pattern} still matches every
692
+ # credential and version variant of a resource.
693
+ # @!visibility private
694
+ def versioned_key(url, versions)
695
+ tag = versions.join(".")
696
+ if @keyspace
697
+ auth = Digest::SHA256.hexdigest("#{@legacy_auth}\x00#{tag}")[0, 32]
698
+ @keyspace.cache_key(url, auth: auth, tenant: @cache_tenant)
699
+ else
700
+ "#{@legacy_cache_key}#v=#{tag}"
701
+ end
364
702
  end
365
703
 
366
704
  # Whether to also evict the pre-keyspace key shape. Always true before a
@@ -383,8 +721,13 @@ module Parse
383
721
  if @store.respond_to?(:delete_matching)
384
722
  @store.delete_matching(pattern)
385
723
  else
724
+ # Pre-credential key shapes, from before entries were bound to the
725
+ # credential and versions. A store that cannot match a pattern has
726
+ # no way to name the current variants of other callers; the
727
+ # version bump in {#delete_cache_variants} retires those instead.
386
728
  @store.delete @keyspace.cache_key(url, auth: :anon, tenant: @cache_tenant)
387
729
  @store.delete @keyspace.cache_key(url, auth: :master, tenant: @cache_tenant)
730
+ @store.delete @keyspace.cache_key(url, auth: @cache_auth, tenant: @cache_tenant)
388
731
  end
389
732
  end
390
733
 
@@ -401,7 +744,8 @@ module Parse
401
744
  @store.delete url.to_s # regular
402
745
  @store.delete "mk:#{url.to_s}" # master key cache-key
403
746
  end
404
- @store.delete @legacy_cache_key if @legacy_cache_key
747
+ # The caller's own entry in the pre-credential shape.
748
+ @store.delete @old_shape_key if @old_shape_key
405
749
  end
406
750
  end #Caching
407
751
  end #Middleware
@@ -109,8 +109,12 @@ module Parse
109
109
  self.method = method
110
110
  self.path = uri
111
111
  self.body = body
112
- self.headers = headers || {}
113
- self.opts = opts || {}
112
+ # Copy the caller's hashes: the request id header is written into
113
+ # `headers` below, and writing it into a hash the caller reuses would
114
+ # send the same id on the caller's next request, which Parse Server's
115
+ # idempotency layer rejects as a duplicate.
116
+ self.headers = headers ? headers.dup : {}
117
+ self.opts = opts ? opts.dup : {}
114
118
 
115
119
  # Handle request ID for idempotency
116
120
  setup_request_id
@@ -199,24 +203,32 @@ module Parse
199
203
  true
200
204
  end
201
205
 
206
+ # Endpoints that never get an automatic request id. Matched against the
207
+ # path with any leading slash and mount prefix allowed, since requests
208
+ # carry both relative (`functions/foo`) and absolute
209
+ # (`/parse/functions/foo`) paths.
210
+ NON_IDEMPOTENT_PATH_PATTERNS = [
211
+ %r{(?:\A|/)sessions(?:/|\z)}, # Session creation/management
212
+ %r{(?:\A|/)logout(?:/|\z)}, # Logout operations
213
+ %r{(?:\A|/)requestPasswordReset(?:/|\z)}, # Password reset requests
214
+ %r{(?:\A|/)functions/}, # Cloud functions (may have their own logic)
215
+ %r{(?:\A|/)jobs/}, # Background jobs
216
+ %r{(?:\A|/)events/}, # Analytics events
217
+ %r{(?:\A|/)push(?:/|\z)}, # Push notifications
218
+ ].freeze
219
+
202
220
  # Checks if the request path should not use request IDs
203
221
  # @return [Boolean]
204
222
  def non_idempotent_path?
205
223
  # GET requests are naturally idempotent
206
224
  return true if @method == :get
207
225
 
208
- # Some Parse endpoints handle their own idempotency or shouldn't be retried
209
- non_idempotent_patterns = [
210
- %r{/sessions}, # Session creation/management
211
- %r{/logout}, # Logout operations
212
- %r{/requestPasswordReset}, # Password reset requests
213
- %r{/functions/}, # Cloud functions (may have their own logic)
214
- %r{/jobs/}, # Background jobs
215
- %r{/events/}, # Analytics events
216
- %r{/push}, # Push notifications
217
- ]
218
-
219
- non_idempotent_patterns.any? { |pattern| @path =~ pattern }
226
+ path = @path.to_s.split("?", 2).first
227
+ # Object paths are never one of the special endpoints, even when the
228
+ # class is named like one (`classes/push`).
229
+ return false if path.match?(%r{(?:\A|/)classes/})
230
+
231
+ NON_IDEMPOTENT_PATH_PATTERNS.any? { |pattern| path.match?(pattern) }
220
232
  end
221
233
 
222
234
  # Generates a unique request ID