parse-stack-next 5.6.0 → 5.7.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/classes/role.rb +227 -28
  75. data/lib/parse/model/classes/user.rb +60 -1
  76. data/lib/parse/model/clp.rb +4 -4
  77. data/lib/parse/model/core/actions.rb +97 -15
  78. data/lib/parse/model/core/create_lock.rb +0 -2
  79. data/lib/parse/model/core/describe.rb +57 -57
  80. data/lib/parse/model/core/embed_managed.rb +21 -23
  81. data/lib/parse/model/core/fetching.rb +2 -0
  82. data/lib/parse/model/core/indexing.rb +14 -14
  83. data/lib/parse/model/core/parse_reference.rb +1 -1
  84. data/lib/parse/model/core/properties.rb +2 -2
  85. data/lib/parse/model/core/querying.rb +1 -1
  86. data/lib/parse/model/core/schema.rb +2 -2
  87. data/lib/parse/model/core/search_indexing.rb +2 -2
  88. data/lib/parse/model/core/vector_searchable.rb +4 -5
  89. data/lib/parse/model/file.rb +21 -15
  90. data/lib/parse/model/geojson.rb +2 -2
  91. data/lib/parse/model/geopoint.rb +1 -0
  92. data/lib/parse/model/object.rb +132 -66
  93. data/lib/parse/model/pointer.rb +2 -0
  94. data/lib/parse/model/polygon.rb +3 -6
  95. data/lib/parse/model/push.rb +2 -2
  96. data/lib/parse/model/vector.rb +3 -1
  97. data/lib/parse/mongodb.rb +512 -282
  98. data/lib/parse/pipeline_security.rb +3 -0
  99. data/lib/parse/query/constraints.rb +46 -46
  100. data/lib/parse/query.rb +146 -59
  101. data/lib/parse/retrieval/agent_tool.rb +34 -20
  102. data/lib/parse/retrieval/chunk.rb +1 -0
  103. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  104. data/lib/parse/retrieval/reranker.rb +3 -4
  105. data/lib/parse/retrieval/retriever.rb +4 -6
  106. data/lib/parse/schema/index_migrator.rb +26 -26
  107. data/lib/parse/schema/search_index_migrator.rb +19 -19
  108. data/lib/parse/stack/tasks.rb +6 -6
  109. data/lib/parse/stack/version.rb +1 -1
  110. data/lib/parse/stack.rb +10 -10
  111. data/lib/parse/vector_search/hybrid.rb +66 -41
  112. data/lib/parse/vector_search.rb +14 -11
  113. data/lib/parse/webhooks/payload.rb +1 -0
  114. data/lib/parse/webhooks/registration.rb +4 -4
  115. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  116. data/lib/parse/webhooks.rb +22 -4
  117. data/parse-stack-next.gemspec +6 -6
  118. metadata +10 -1
@@ -0,0 +1,443 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "keyspace"
5
+ require_relative "moneta_surface"
6
+
7
+ module Parse
8
+ module Cache
9
+ # An immutable, per-client view over one shared {Parse::Cache::Redis}
10
+ # backend.
11
+ #
12
+ # {Parse::Cache::Redis} is a connection pool: sharing ONE backend across
13
+ # several {Parse::Client} instances (one Redis, several Parse apps) is a
14
+ # normal and supported deployment. What is not supported is sharing
15
+ # *ownership* of a single keyspace binding, which is what the old
16
+ # `Parse::Cache::Redis#keyspace=` setter allowed: client B calling
17
+ # `keyspace = ks_b` on a backend client A already configured rebinds the
18
+ # one `@keyspace` ivar out from under A. A's caching middleware had
19
+ # already captured A's keyspace object and keeps writing/reading under
20
+ # it, while `clear`, `identity`, `roles`, and the memoized
21
+ # `upstream_roles` on the now-shared backend answer with B's keyspace
22
+ # instead. A stops invalidating its own entries, or worse, a scoped
23
+ # `clear` issued through A now deletes B's keys.
24
+ #
25
+ # A `ScopedView` closes that hole by never mutating the backend at all.
26
+ # `Parse::Cache::Redis#scoped(keyspace)` hands back one of these per
27
+ # caller, each carrying its own keyspace and its own memoized
28
+ # `identity` / `roles` / `upstream_roles` planes, all backed by the same
29
+ # underlying connection pool:
30
+ #
31
+ # backend = Parse::Cache::Redis.new(url: redis_url)
32
+ # view_a = backend.scoped(keyspace_a)
33
+ # view_b = backend.scoped(keyspace_b)
34
+ #
35
+ # `view_a` and `view_b` share `backend`'s pooled Redis connections but
36
+ # can never see or clear each other's keys.
37
+ #
38
+ # **Moneta surface.** Implements `[]`, `key?`, `delete`, `store` (and
39
+ # `create` / `increment` when the backend supports them) by delegating
40
+ # straight to the backend, so a view is a drop-in replacement anywhere a
41
+ # bare `Parse::Cache::Redis` was accepted: most importantly, the Faraday
42
+ # caching middleware.
43
+ #
44
+ # **Locks are NOT scoped.** `lock_acquire` / `lock_release` delegate to
45
+ # the backend unchanged, still using the historical
46
+ # `parse-stack:foc:v1:` prefix from {Parse::CreateLock}. During a
47
+ # rolling deploy two workers must compute the SAME lock key regardless of
48
+ # which client/keyspace they were configured with, or `first_or_create!`
49
+ # silently loses cross-process mutual exclusion for the length of the
50
+ # deploy.
51
+ #
52
+ # **No `flush_db!`.** A whole-database flush is a connection-level
53
+ # operation, not something a scoped view over one client's slice of the
54
+ # keyspace should be able to trigger.
55
+ class ScopedView
56
+ include Parse::Cache::MonetaSurface
57
+
58
+ # Safe defaults for callers that create a plane implicitly, most notably
59
+ # the webhook invalidation handlers. Keep these aligned with
60
+ # Parse::Authorization::Context's defaults: the first accessor call
61
+ # memoizes the plane and therefore fixes its generation lifetime.
62
+ DEFAULT_IDENTITY_TTL = 3600
63
+ DEFAULT_ROLE_TTL = 30
64
+
65
+ # @return [Parse::Cache::Keyspace] this view's key layout. Fixed at
66
+ # construction; there is no setter, so a view can never be rebound to
67
+ # a different keyspace after the fact.
68
+ attr_reader :keyspace
69
+
70
+ # @return [Parse::Cache::Redis] the shared backend this view reads and
71
+ # writes through. Exposed for introspection (e.g. tests asserting two
72
+ # views share one connection pool). The backend itself has no
73
+ # keyspace concept at all anymore ({#scoped} is the only way to
74
+ # associate a keyspace with it), so there is nothing on the backend
75
+ # left to rebind out from under this or any other view.
76
+ attr_reader :backend
77
+
78
+ # @param backend [Parse::Cache::Redis] the shared connection pool.
79
+ # @param keyspace [Parse::Cache::Keyspace] this view's key layout.
80
+ # @raise [ArgumentError] if `keyspace` is not a Parse::Cache::Keyspace.
81
+ def initialize(backend:, keyspace:)
82
+ unless keyspace.is_a?(Parse::Cache::Keyspace)
83
+ raise ArgumentError,
84
+ "Parse::Cache::ScopedView keyspace must be a Parse::Cache::Keyspace; got #{keyspace.class}"
85
+ end
86
+ @backend = backend
87
+ # The keyspace itself has no setters to begin with, but freezing it
88
+ # here is a cheap extra guarantee that nothing downstream (this view
89
+ # included) can mutate the layout this view was constructed with.
90
+ @keyspace = keyspace.freeze
91
+
92
+ # `create` and `increment` are only defined when the backend itself
93
+ # supports them, so `respond_to?(:create)` on a view accurately
94
+ # reflects what the backend can actually do rather than always
95
+ # claiming support and raising NoMethodError on first use.
96
+ if @backend.respond_to?(:create)
97
+ define_singleton_method(:create) do |key, value, options = {}|
98
+ @backend.create(key, value, options)
99
+ end
100
+ end
101
+ if @backend.respond_to?(:increment)
102
+ define_singleton_method(:increment) do |key, amount = 1, options = {}|
103
+ @backend.increment(key, amount, options)
104
+ end
105
+ end
106
+ # Value-preserving TTL. SubCache feature-detects this to avoid the
107
+ # re-store that would lose a concurrent generation increment.
108
+ if @backend.respond_to?(:expire)
109
+ define_singleton_method(:expire) do |key, ttl|
110
+ @backend.expire(key, ttl)
111
+ end
112
+ end
113
+ end
114
+
115
+ # --- Moneta response-cache interface ---------------------------------
116
+ # Delegate straight to the shared backend. These four methods are all
117
+ # the Faraday caching middleware requires, so a view is a drop-in
118
+ # replacement for a bare Parse::Cache::Redis.
119
+
120
+ # `load` is the read primitive, delegated to the backend so the options
121
+ # argument reaches something that can honor it. Deriving it from `[]`
122
+ # here is what produced infinite recursion in an earlier version.
123
+ def load(key, options = {})
124
+ @backend.load(key, options || {})
125
+ end
126
+
127
+ def [](key)
128
+ load(key, {})
129
+ end
130
+
131
+ def key?(key, options = {})
132
+ @backend.key?(key, options || {})
133
+ end
134
+
135
+ def delete(key, options = {})
136
+ @backend.delete(key, options || {})
137
+ end
138
+
139
+ def store(key, value, options = {})
140
+ @backend.store(key, value, options || {})
141
+ end
142
+
143
+ # --- scoped eviction --------------------------------------------------
144
+
145
+ # Clear cached entries belonging to THIS view's keyspace, and nothing
146
+ # else: never anything from another view over the same backend.
147
+ #
148
+ # @param scope [String, nil] explicit namespace prefix to scan-delete,
149
+ # narrowed inside this view's root_prefix. See
150
+ # {Parse::Cache::Redis#clear} for the exact semantics; the difference
151
+ # here is that there is no unscoped/FLUSHDB fallback branch to fall
152
+ # into, because a view always has a keyspace.
153
+ # @param family [Symbol, String, nil] narrow to one family.
154
+ # @param tenant [String, nil] narrow to one tenant (requires `family`).
155
+ # @return [self]
156
+ def clear(scope: nil, family: nil, tenant: nil)
157
+ if scope
158
+ prefix = @backend.send(:validate_scope!, scope)
159
+ raw_delete_matching!("#{@keyspace.root_prefix}:#{prefix}:*")
160
+ else
161
+ raw_delete_matching!(@keyspace.pattern(family: family, tenant: tenant))
162
+ end
163
+ self
164
+ end
165
+
166
+ # Delete every key matching a glob pattern, refusing anything outside
167
+ # this view's own keyspace.
168
+ #
169
+ # The check requires the segment boundary (`root_prefix` followed by
170
+ # `:`), not a bare string prefix: every pattern this keyspace actually
171
+ # generates ({Parse::Cache::Keyspace#pattern},
172
+ # {Parse::Cache::Keyspace#resource_pattern}) is `"<root_prefix>:..."`,
173
+ # so this rejects nothing legitimate. A bare `start_with?(root_prefix)`
174
+ # would accept a pattern belonging to a DIFFERENT namespace that merely
175
+ # shares a prefix: a view whose namespace is `"foo"` would happily
176
+ # delete_matching a pattern for namespace `"foobar"`, since the string
177
+ # `"...:foobar:..."` starts with `"...:foo"`.
178
+ #
179
+ # @param pattern [String] a Redis glob pattern.
180
+ # @return [Integer] number of keys removed.
181
+ def delete_matching(pattern)
182
+ return 0 if pattern.nil? || pattern.to_s.empty?
183
+ return 0 unless pattern.to_s.start_with?("#{@keyspace.root_prefix}:")
184
+ raw_delete_matching!(pattern)
185
+ end
186
+
187
+ # --- identity / role / upstream planes, one instance per view --------
188
+ # Never shared with the backend's own (legacy, unscoped) `identity` /
189
+ # `roles` / `upstream_roles`, and never shared between two views over
190
+ # the same backend.
191
+
192
+ # @param ttl [Integer, nil] default entry TTL; explicitly pass nil only
193
+ # for a deliberately permanent plane.
194
+ # @return [Parse::Cache::SubCache]
195
+ def identity(ttl: DEFAULT_IDENTITY_TTL)
196
+ @identity ||= Parse::Cache::SubCache.new(store: self, keyspace: @keyspace, family: :idn, ttl: ttl)
197
+ end
198
+
199
+ # @param ttl [Integer, nil] default entry TTL; explicitly pass nil only
200
+ # for a deliberately permanent plane.
201
+ # @return [Parse::Cache::SubCache]
202
+ def roles(ttl: DEFAULT_ROLE_TTL)
203
+ @roles ||= Parse::Cache::SubCache.new(store: self, keyspace: @keyspace, family: :role, ttl: ttl)
204
+ end
205
+
206
+ # Read-only consumer of Parse Server's own role cache, scoped to this
207
+ # view's app id and role plane. See {Parse::Cache::Redis#upstream_roles}
208
+ # for the full rationale; the only difference here is that the app id
209
+ # and roles plane come from THIS view's keyspace, never a sibling
210
+ # view's.
211
+ # @return [Parse::Cache::UpstreamRoles, nil]
212
+ def upstream_roles
213
+ return nil if @backend.parse_cache_url.nil?
214
+ @upstream_roles ||= Parse::Cache::UpstreamRoles.new(
215
+ client: @backend.send(:upstream_client),
216
+ app_id: @keyspace.app_id,
217
+ roles_plane: roles,
218
+ )
219
+ end
220
+
221
+ # --- locks: unscoped, delegated straight to the backend ---------------
222
+
223
+ # @see Parse::Cache::Redis#lock_acquire
224
+ def lock_acquire(key, owner, ttl)
225
+ @backend.lock_acquire(key, owner, ttl)
226
+ end
227
+
228
+ # @see Parse::Cache::Redis#lock_release
229
+ def lock_release(key, owner)
230
+ @backend.lock_release(key, owner)
231
+ end
232
+
233
+ def inspect
234
+ "#<Parse::Cache::ScopedView #{@keyspace.root_prefix}>"
235
+ end
236
+
237
+ private
238
+
239
+ # Unguarded SCAN+UNLINK against the shared backend. Safe to call here
240
+ # because both public callers above ({#clear}, {#delete_matching})
241
+ # have already confined `pattern` to this view's own root_prefix
242
+ # before reaching this point.
243
+ def raw_delete_matching!(pattern)
244
+ @backend.send(:delete_keys_matching!, pattern)
245
+ end
246
+ end
247
+
248
+ # Raised when a keyspaced client is asked to clear a cache store that
249
+ # cannot restrict the clear to its own keyspace.
250
+ class UnscopedClearRefused < StandardError; end
251
+
252
+ # Keyspace wrapper for a cache store that is not a
253
+ # {Parse::Cache::Redis} and therefore cannot produce a
254
+ # {Parse::Cache::ScopedView}.
255
+ #
256
+ # `cache_keyspace: true` used to leave such a store installed bare. Key
257
+ # composition still worked, because the caching middleware receives the
258
+ # keyspace directly, so the deployment looked correctly keyspaced. But
259
+ # `Parse::Client#clear_cache!` called the store's own `clear`, and on a
260
+ # plain `Moneta.new(:Redis)` that is `FLUSHDB`. Asking for keyspacing and
261
+ # receiving a database-wide flush inverts the entire point of the option:
262
+ # it deletes other applications' entries and, on a shared database, the
263
+ # `parse-stack:foc:v1:*` create-locks, so a `first_or_create!` holding a
264
+ # lock at that moment silently loses mutual exclusion.
265
+ #
266
+ # This wrapper keeps the store usable for reads and writes and makes the
267
+ # clear honest. Where the store can enumerate its keys (Moneta's
268
+ # `each_key` feature), the clear is scan-and-delete confined to the
269
+ # keyspace, matching what a `ScopedView` does. Where it cannot, the clear
270
+ # raises rather than widening: a store that cannot express "delete only
271
+ # my keys" has no safe answer, and the wrong answer is unrecoverable.
272
+ class KeyspacedStore
273
+ include Parse::Cache::MonetaSurface
274
+
275
+ # @return [Parse::Cache::Keyspace]
276
+ attr_reader :keyspace
277
+
278
+ # @return [Object] the wrapped store. Named `wrapped` rather than
279
+ # `store` because `store` is Moneta's writer method, which this class
280
+ # must keep implementing for the Faraday caching middleware.
281
+ attr_reader :wrapped
282
+
283
+ def initialize(store:, keyspace:)
284
+ unless keyspace.is_a?(Parse::Cache::Keyspace)
285
+ raise ArgumentError,
286
+ "Parse::Cache::KeyspacedStore keyspace must be a Parse::Cache::Keyspace; got #{keyspace.class}"
287
+ end
288
+ @wrapped = store
289
+ @keyspace = keyspace.freeze
290
+
291
+ # Mirror the wrapped store's optional capabilities rather than always
292
+ # claiming them, so `respond_to?` stays truthful and callers that
293
+ # feature-detect (the create-lock path, SubCache's atomic increment)
294
+ # get the same answer they would from the store itself.
295
+ # `fetch` and `each_key` are deliberately NOT in this list.
296
+ #
297
+ # `fetch` must come from {Parse::Cache::MonetaSurface}, which routes
298
+ # through this wrapper's own `load`. Delegating it handed the call
299
+ # straight to the wrapped store and skipped the wrapper entirely.
300
+ #
301
+ # `each_key` is defined below with keyspace filtering. Delegating it
302
+ # enumerated the WHOLE store, so `sdk_cache.each_key` returned the
303
+ # application's keys alongside the SDK's, which is precisely the
304
+ # confusion this class exists to prevent.
305
+ %i[create increment expire features lock_acquire lock_release].each do |name|
306
+ next unless wrapped_supports?(name)
307
+ define_singleton_method(name) { |*args, **kw, &blk| @wrapped.public_send(name, *args, **kw, &blk) }
308
+ end
309
+
310
+ # Only claim `each_key` when the wrapped store can genuinely enumerate.
311
+ if wrapped_supports?(:each_key)
312
+ define_singleton_method(:each_key) do |&blk|
313
+ return enum_for(:each_key) unless blk
314
+ prefix = "#{@keyspace.root_prefix}:"
315
+ @wrapped.each_key { |k| blk.call(k) if k.to_s.start_with?(prefix) }
316
+ self
317
+ end
318
+ end
319
+
320
+ # Probe arity ONCE instead of rescuing at call time. See #key?.
321
+ @options_aware = %i[key? delete].to_h { |name| [name, accepts_options?(name)] }
322
+ end
323
+
324
+ # --- Moneta response-cache interface ---------------------------------
325
+
326
+ # Delegated primitives. `load` falls back to `[]` for a wrapped store
327
+ # that predates Moneta's options argument, so an older custom store
328
+ # still works and simply ignores the hint.
329
+ def load(key, options = {})
330
+ return @wrapped.load(key, options || {}) if @wrapped.respond_to?(:load)
331
+ @wrapped[key]
332
+ end
333
+
334
+ def [](key) = load(key, {})
335
+ def store(key, value, options = {}) = @wrapped.store(key, value, options || {})
336
+
337
+ # Options are forwarded when the wrapped store can take them, decided
338
+ # by arity at construction.
339
+ #
340
+ # This used to call with options and `rescue ArgumentError` to retry
341
+ # without them. That is wrong twice over: a store's own argument
342
+ # validation also raises ArgumentError, so a genuine rejection was
343
+ # retried rather than surfaced, and for `delete` the retry meant the
344
+ # deletion could run TWICE, once before the raise and once after.
345
+ # Deciding from arity is exact and happens once.
346
+ def key?(key, options = {})
347
+ return @wrapped.key?(key, options || {}) if @options_aware[:key?]
348
+ @wrapped.key?(key)
349
+ end
350
+
351
+ def delete(key, options = {})
352
+ return @wrapped.delete(key, options || {}) if @options_aware[:delete]
353
+ @wrapped.delete(key)
354
+ end
355
+
356
+ # Delete every entry under this keyspace, and nothing else.
357
+ #
358
+ # @raise [Parse::Cache::UnscopedClearRefused] when the wrapped store
359
+ # cannot enumerate its keys, and therefore cannot clear within a
360
+ # keyspace. Use a {Parse::Cache::Redis} for scoped clearing, or call
361
+ # `client.cache.clear` to take the unscoped clear deliberately.
362
+ # @return [self]
363
+ def clear(scope: nil, family: nil, tenant: nil)
364
+ prefix = if scope
365
+ "#{@keyspace.root_prefix}:#{scope.to_s.sub(/:\z/, "")}:"
366
+ else
367
+ pattern = @keyspace.pattern(family: family, tenant: tenant)
368
+ pattern.sub(/\*\z/, "")
369
+ end
370
+
371
+ unless wrapped_supports?(:each_key)
372
+ raise UnscopedClearRefused,
373
+ "#{@wrapped.class} cannot enumerate its keys, so it cannot clear only the entries " \
374
+ "under #{@keyspace.root_prefix}. Refusing rather than falling back to an " \
375
+ "unscoped clear, which on a Redis-backed store is FLUSHDB and would delete " \
376
+ "other applications' entries and any parse-stack:foc:v1:* create-locks. " \
377
+ "Use Parse::Cache::Redis for scoped clearing, or call " \
378
+ "client.cache.clear to take the unscoped clear deliberately."
379
+ end
380
+
381
+ # Collect before deleting: mutating during enumeration is undefined
382
+ # across Moneta adapters.
383
+ doomed = []
384
+ @wrapped.each_key { |k| doomed << k if k.to_s.start_with?(prefix) }
385
+ doomed.each { |k| @wrapped.delete(k) }
386
+ self
387
+ end
388
+
389
+ # @param pattern [String] a glob pattern, refused unless it sits inside
390
+ # this keyspace.
391
+ # @return [Integer] number of keys removed.
392
+ def delete_matching(pattern)
393
+ return 0 if pattern.nil? || pattern.to_s.empty?
394
+ return 0 unless pattern.to_s.start_with?("#{@keyspace.root_prefix}:")
395
+ return 0 unless wrapped_supports?(:each_key)
396
+
397
+ doomed = []
398
+ @wrapped.each_key { |k| doomed << k if File.fnmatch(pattern, k.to_s, File::FNM_NOESCAPE) }
399
+ doomed.each { |k| @wrapped.delete(k) }
400
+ doomed.size
401
+ end
402
+
403
+ def inspect
404
+ "#<Parse::Cache::KeyspacedStore #{@keyspace.root_prefix} over #{@wrapped.class}>"
405
+ end
406
+
407
+ private
408
+
409
+ # Whether the wrapped store genuinely provides a capability.
410
+ #
411
+ # `respond_to?` is not enough for a Moneta store. Moneta defines every
412
+ # optional method on every store and has the unsupported ones raise
413
+ # `NotImplementedError`, so `Moneta.new(:Null).respond_to?(:each_key)`
414
+ # is true while calling it raises. Advertising it on that basis made
415
+ # this wrapper claim `each_key`, `create`, and `increment` for a store
416
+ # that has none of them, and a scoped `clear` then leaked
417
+ # `NotImplementedError` instead of the `UnscopedClearRefused` this class
418
+ # promises. `supports?` is Moneta's own answer to that question.
419
+ #
420
+ # It only covers Moneta's feature vocabulary, so anything outside it
421
+ # (`expire`, the lock pair) still falls back to `respond_to?`.
422
+ MONETA_FEATURES = %i[create increment each_key].freeze
423
+ private_constant :MONETA_FEATURES
424
+
425
+ def wrapped_supports?(name)
426
+ return false unless @wrapped.respond_to?(name)
427
+ return true unless MONETA_FEATURES.include?(name)
428
+ return true unless @wrapped.respond_to?(:supports?)
429
+ !!@wrapped.supports?(name)
430
+ rescue StandardError
431
+ false
432
+ end
433
+
434
+ # @return [Boolean] whether the wrapped method takes an options argument.
435
+ def accepts_options?(name)
436
+ arity = @wrapped.method(name).arity
437
+ arity.negative? || arity >= 2
438
+ rescue NameError
439
+ false
440
+ end
441
+ end
442
+ end
443
+ end