parse-stack-next 5.6.0 → 5.7.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -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 +219 -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/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
@@ -0,0 +1,306 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ module Parse
7
+ module Cache
8
+ # Owns the physical layout of every key this SDK writes to a shared cache
9
+ # backend, and the patterns used to delete them again.
10
+ #
11
+ # A single object generates keys *and* the patterns that clear them, so the
12
+ # two can never disagree. Previously the caching middleware composed keys
13
+ # from a `cache_namespace:` it held privately while `Parse::Cache::Redis`
14
+ # held a separate `namespace`, and `Parse::Client#clear_cache!` cleared
15
+ # using only the latter. A client namespaced at the middleware but not at
16
+ # the wrapper would therefore delete every SDK key on the database rather
17
+ # than its own.
18
+ #
19
+ # Layout:
20
+ #
21
+ # parse-stack:<version>:<app_scope>[:<namespace>]:<family>[:T:<tenant>]:<rest>
22
+ #
23
+ # Every clear is a strict prefix of this, so narrowing the scope can only
24
+ # ever delete a subset:
25
+ #
26
+ # all of this client parse-stack:v1:<app_scope>[:<ns>]:*
27
+ # one family parse-stack:v1:<app_scope>[:<ns>]:<family>:*
28
+ # one tenant parse-stack:v1:<app_scope>[:<ns>]:<family>:T:<tenant>:*
29
+ #
30
+ # `app_scope` is a digest of the Parse application id and server URL rather
31
+ # than the raw values. Two apps sharing one Redis with no namespace
32
+ # configured would otherwise collide, and a raw application id could carry
33
+ # glob metacharacters (`*`, `[`) that would silently widen a SCAN pattern.
34
+ # The digest is fixed-length and glob-safe by construction.
35
+ #
36
+ # Create-locks are deliberately NOT part of this layout. They keep the
37
+ # historical `parse-stack:foc:v1:` prefix from {Parse::Model::CreateLock}.
38
+ # Moving them would mean that during a rolling deploy two workers compute
39
+ # different lock keys, stop contending on the same key, and silently lose
40
+ # mutual exclusion for the length of the deploy.
41
+ class Keyspace
42
+ # Root segment for every key this SDK owns.
43
+ ROOT = "parse-stack"
44
+
45
+ # Layout version. Bump only for a breaking key-shape change, which
46
+ # orphans every existing entry and therefore needs a migration note.
47
+ VERSION = "v1"
48
+
49
+ # Occupies the namespace position when no namespace is configured. Chosen
50
+ # because {#normalize_segment} rejects it as caller input, so a caller can
51
+ # never collide with the unnamespaced keyspace by naming their namespace
52
+ # after the sentinel.
53
+ NO_NAMESPACE = "_"
54
+
55
+ # Key families. `cache` is the Faraday response cache, `idn` is
56
+ # session-token identity, `role` is role closures.
57
+ FAMILIES = %i[cache idn role].freeze
58
+
59
+ # Length of the truncated app/server digest. 12 hex characters is 48
60
+ # bits, which is ample for distinguishing apps on one database while
61
+ # keeping keys readable.
62
+ SCOPE_DIGEST_LENGTH = 12
63
+
64
+ # Characters that carry meaning in a Redis glob pattern. A namespace or
65
+ # tenant containing one of these could widen a SCAN pattern beyond its
66
+ # intended scope, so they are rejected at construction rather than
67
+ # escaped, since a caller passing one is a configuration bug.
68
+ # `:` is included deliberately alongside the glob metacharacters. It is the
69
+ # segment separator, so a namespace of `foo:bar` would forge an extra
70
+ # segment and make the pattern for `foo` also match `foo:bar`, which is
71
+ # the same subset violation the sentinel above prevents.
72
+ GLOB_METACHARS = /[\*\?\[\]\\\x00:]/.freeze
73
+
74
+ # @return [String] digest identifying the Parse app and server.
75
+ attr_reader :app_scope
76
+
77
+ # @return [String, nil] the raw Parse application id. Retained alongside
78
+ # the digest because Parse Server's own cache keys use it verbatim
79
+ # (`<appId>:role:<userId>`), so an attached read needs the original
80
+ # even though our own layout uses the digest.
81
+ attr_reader :app_id
82
+
83
+ # @return [String, nil] validated namespace, or nil when unset.
84
+ attr_reader :namespace
85
+
86
+ # @return [String] layout version segment.
87
+ attr_reader :version
88
+
89
+ # @param app_id [String, nil] the Parse application id.
90
+ # @param server_url [String, nil] the Parse server URL.
91
+ # @param namespace [String, nil] optional operator-supplied namespace.
92
+ # @param version [String] layout version, for tests and migrations.
93
+ # @raise [ArgumentError] if the namespace is unusable as a key segment.
94
+ def initialize(app_id: nil, server_url: nil, namespace: nil, version: VERSION)
95
+ @app_id = app_id&.to_s
96
+ @app_scope = self.class.digest_scope(app_id, server_url)
97
+ @namespace = normalize_segment(namespace, "namespace")
98
+ @version = version.to_s
99
+ end
100
+
101
+ # Digest of the app id and server URL. Public so callers can compare two
102
+ # keyspaces for equivalence without reaching into internals.
103
+ #
104
+ # @return [String] fixed-length, glob-safe hex digest.
105
+ def self.digest_scope(app_id, server_url)
106
+ material = "#{app_id}\x00#{server_url}"
107
+ Digest::SHA256.hexdigest(material)[0, SCOPE_DIGEST_LENGTH]
108
+ end
109
+
110
+ # Prefix shared by every key this keyspace owns, across all families.
111
+ # @return [String]
112
+ def root_prefix
113
+ # The namespace segment is ALWAYS emitted, using NO_NAMESPACE when unset.
114
+ # Without it an unnamespaced root is a strict prefix of every namespaced
115
+ # root for the same app, so `<root>:*` would also delete every named
116
+ # namespace's keys. Narrowing must only ever delete a subset, and a
117
+ # sentinel is the only way to keep the segment count fixed.
118
+ [ROOT, @version, @app_scope, @namespace || NO_NAMESPACE].join(":")
119
+ end
120
+
121
+ # Prefix for one family.
122
+ # @param family [Symbol, String]
123
+ # @return [String]
124
+ # @raise [ArgumentError] on an unknown family.
125
+ def family_prefix(family)
126
+ "#{root_prefix}:#{assert_family!(family)}"
127
+ end
128
+
129
+ # Build a key for the `idn` or `role` families.
130
+ #
131
+ # Neither takes an auth discriminator: a role key is keyed by user id and
132
+ # an identity key by session token, so the key already *is* the auth
133
+ # identity. Only the response cache has one URL yielding different bodies
134
+ # to different callers, and it uses {#cache_key}.
135
+ #
136
+ # @param family [Symbol, String] `:idn` or `:role`.
137
+ # @param segments [Array<String>] trailing key segments, joined with `:`.
138
+ # Not validated for glob characters: they are only ever written and read
139
+ # as literal keys, never used as a SCAN pattern.
140
+ # @param tenant [String, nil] ambient cache tenant, if any.
141
+ # @return [String]
142
+ # @raise [ArgumentError] if called for the `cache` family.
143
+ def key(family, *segments, tenant: nil)
144
+ if family.to_sym == :cache
145
+ raise ArgumentError,
146
+ "Parse::Cache::Keyspace: use #cache_key for the cache family, " \
147
+ "so the auth discriminator cannot be omitted"
148
+ end
149
+ parts = [family_prefix(family)]
150
+ parts << "T:#{normalize_segment(tenant, "tenant")}" unless tenant.nil?
151
+ parts.concat(segments.map(&:to_s))
152
+ parts.join(":")
153
+ end
154
+
155
+ # Build a response-cache key.
156
+ #
157
+ # `auth:` is mandatory and has no default. A master-key request bypasses
158
+ # ACL, CLP and `protectedFields`, so the same URL returns a strictly
159
+ # fuller body than a session-token request, and two different sessions can
160
+ # differ from each other through `protectedFields` entity rules and row
161
+ # ACLs. Collapsing those into one key would serve privileged fields to an
162
+ # unprivileged caller out of the cache, so the key cannot be built without
163
+ # stating which auth produced the body.
164
+ #
165
+ # The URL is digested and placed *before* the discriminator so that every
166
+ # auth variant of one resource shares a prefix, which is what makes
167
+ # {#resource_pattern} able to invalidate a write for all callers rather
168
+ # than only the three variants the old `delete_cache_variants` could name.
169
+ #
170
+ # @param url [String] the request URL.
171
+ # @param auth [Symbol, String] `:anon`, `:master`, or a session-token digest.
172
+ # @param tenant [String, nil] ambient cache tenant, if any.
173
+ # @return [String]
174
+ def cache_key(url, auth:, tenant: nil)
175
+ "#{resource_prefix(url, tenant: tenant)}:#{normalize_auth(auth)}"
176
+ end
177
+
178
+ # Pattern matching every auth variant of one resource. Used to invalidate
179
+ # a resource on write for all callers, including sessions this process
180
+ # has never seen.
181
+ #
182
+ # @param url [String] the request URL.
183
+ # @param tenant [String, nil] ambient cache tenant, if any.
184
+ # @return [String]
185
+ def resource_pattern(url, tenant: nil)
186
+ "#{resource_prefix(url, tenant: tenant)}:*"
187
+ end
188
+
189
+ # Glob pattern selecting keys to clear. With no arguments it selects
190
+ # every key this keyspace owns and nothing else.
191
+ #
192
+ # @param family [Symbol, String, nil] narrow to one family.
193
+ # @param tenant [String, nil] narrow to one tenant. Requires `family`,
194
+ # since tenant is positioned inside the family segment.
195
+ # @return [String]
196
+ # @raise [ArgumentError] if a tenant is given without a family.
197
+ def pattern(family: nil, tenant: nil)
198
+ if tenant && family.nil?
199
+ raise ArgumentError,
200
+ "Parse::Cache::Keyspace#pattern requires a family: when a tenant: is given"
201
+ end
202
+ return "#{root_prefix}:*" if family.nil?
203
+
204
+ prefix = family_prefix(family)
205
+ return "#{prefix}:*" if tenant.nil?
206
+ "#{prefix}:T:#{normalize_segment(tenant, "tenant")}:*"
207
+ end
208
+
209
+ # Whether two keyspaces address the same key space. Used to detect a
210
+ # client reconfigured with a different namespace or app.
211
+ # @return [Boolean]
212
+ def ==(other)
213
+ other.is_a?(Keyspace) && other.root_prefix == root_prefix
214
+ end
215
+
216
+ alias eql? ==
217
+
218
+ def hash
219
+ root_prefix.hash
220
+ end
221
+
222
+ def to_s
223
+ root_prefix
224
+ end
225
+
226
+ def inspect
227
+ "#<Parse::Cache::Keyspace #{root_prefix}>"
228
+ end
229
+
230
+ # Auth discriminator for an anonymous (unauthenticated) request.
231
+ AUTH_ANON = "anon"
232
+
233
+ # Auth discriminator for a master-key request.
234
+ AUTH_MASTER = "mk"
235
+
236
+ # A session-token discriminator must look like the truncated SHA-256 the
237
+ # caching middleware produces. Anything else is a caller bug, and a raw
238
+ # token must never reach a key.
239
+ TOKEN_DIGEST_RE = /\A[0-9a-f]{16,64}\z/.freeze
240
+
241
+ private
242
+
243
+ # Prefix shared by every auth variant of one resource.
244
+ def resource_prefix(url, tenant: nil)
245
+ parts = [family_prefix(:cache)]
246
+ parts << "T:#{normalize_segment(tenant, "tenant")}" unless tenant.nil?
247
+ parts << Digest::SHA256.hexdigest(url.to_s)
248
+ parts.join(":")
249
+ end
250
+
251
+ def normalize_auth(auth)
252
+ case auth
253
+ when :anon, "anon" then AUTH_ANON
254
+ when :master, "master", "mk" then AUTH_MASTER
255
+ when String
256
+ unless auth.match?(TOKEN_DIGEST_RE)
257
+ raise ArgumentError,
258
+ "Parse::Cache::Keyspace auth: must be :anon, :master, or a hex " \
259
+ "session-token digest; a raw session token must never be used as " \
260
+ "a key segment"
261
+ end
262
+ auth
263
+ else
264
+ raise ArgumentError,
265
+ "Parse::Cache::Keyspace auth: must be :anon, :master, or a hex " \
266
+ "session-token digest; got #{auth.class}"
267
+ end
268
+ end
269
+
270
+ def assert_family!(family)
271
+ sym = family.to_sym
272
+ unless FAMILIES.include?(sym)
273
+ raise ArgumentError,
274
+ "Parse::Cache::Keyspace family must be one of #{FAMILIES.join(", ")}; got #{family.inspect}"
275
+ end
276
+ sym.to_s
277
+ end
278
+
279
+ # Normalize an operator-supplied key segment. Returns nil for an
280
+ # absent/empty value so callers can treat "unset" uniformly.
281
+ def normalize_segment(value, label)
282
+ return nil if value.nil?
283
+ unless value.is_a?(String) || value.is_a?(Symbol)
284
+ raise ArgumentError,
285
+ "Parse::Cache::Keyspace #{label} must be a String or Symbol; got #{value.class}"
286
+ end
287
+ segment = value.to_s.chomp(":")
288
+ return nil if segment.empty?
289
+ if segment == NO_NAMESPACE
290
+ raise ArgumentError,
291
+ "Parse::Cache::Keyspace #{label} must not be #{NO_NAMESPACE.inspect}; " \
292
+ "that value is reserved for the unnamespaced keyspace"
293
+ end
294
+ if segment.match?(GLOB_METACHARS)
295
+ raise ArgumentError,
296
+ "Parse::Cache::Keyspace #{label} must not contain \":\", Redis glob " \
297
+ "characters (*, ?, [, ], \\), or NUL; got #{value.inspect}. \":\" is the " \
298
+ "segment separator, so allowing it would let one value forge extra key " \
299
+ "segments and escape its own part of the keyspace. A single trailing " \
300
+ "\":\" is stripped for convenience, so \"web\" and \"web:\" are equivalent."
301
+ end
302
+ segment
303
+ end
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,126 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Cache
6
+ # The derived half of Moneta's store interface.
7
+ #
8
+ # Moneta gets these from `Moneta::Defaults`, which the SDK's own store
9
+ # wrappers do not include. That gap was invisible for a long time because
10
+ # the Faraday caching middleware only ever calls the primitives. It is not
11
+ # invisible to applications: the README has documented
12
+ # `Parse.cache["key"] = value` and `Parse.cache.fetch(...)` for years, and
13
+ # neither existed on {Parse::Cache::Redis}. Anyone following the
14
+ # documentation got `NoMethodError`.
15
+ #
16
+ # **Required primitives.** An including class must implement `load`,
17
+ # `store`, `delete`, and `key?`, each taking Moneta's options argument.
18
+ # `load` is the read primitive, NOT `[]`: an earlier version of this
19
+ # module derived `load` from `[]` and consulted a `moneta_backing_store`
20
+ # that defaulted to `self`, so `load(key, expires: 60)` asked whether self
21
+ # responded to the method it was already executing and recursed until
22
+ # `SystemStackError`. Every option-carrying read went the same way, since
23
+ # `fetch`, `values_at`, `slice`, and `fetch_values` all route through it.
24
+ # A wrapper knows how to reach its own backing store; this module does
25
+ # not, and should not guess.
26
+ #
27
+ # Optional capabilities that cannot be derived (`create`, `increment`,
28
+ # `decrement`, `expire`) are deliberately absent: they need backend
29
+ # support, and claiming them unconditionally would turn a feature check
30
+ # into a runtime error. Feature-detect those with `respond_to?`.
31
+ module MonetaSurface
32
+ # @param key [String]
33
+ # @return [Object, nil]
34
+ def [](key)
35
+ load(key, {})
36
+ end
37
+
38
+ # @param key [String]
39
+ # @param value [Object]
40
+ # @return [Object] the stored value, so assignment chains as Ruby
41
+ # expects.
42
+ def []=(key, value)
43
+ store(key, value, {})
44
+ value
45
+ end
46
+
47
+ # Copied from `Moneta::Defaults#fetch`, deliberately line for line.
48
+ #
49
+ # Its two shapes read the second positional differently: without a block
50
+ # it is the default value, with one it is the OPTIONS hash and the block
51
+ # supplies the fallback. Passing both a block and a third argument is an
52
+ # error, not something to silently absorb.
53
+ #
54
+ # This has now been wrong twice from interpretation: first treating the
55
+ # second argument as a default in both shapes, which dropped the
56
+ # options, then accepting a block alongside a third argument and
57
+ # ignoring one of the two hashes. Reproducing the upstream method is
58
+ # cheaper than continuing to infer it.
59
+ #
60
+ # @param key [String]
61
+ # @param default [Object] the fallback without a block, the options with
62
+ # one.
63
+ # @param options [Hash, nil]
64
+ # @raise [ArgumentError] when given both a block and `options`.
65
+ # @return [Object]
66
+ def fetch(key, default = nil, options = nil)
67
+ if block_given?
68
+ raise ArgumentError, "Only one argument accepted if block is given" if options
69
+ result = load(key, default || {})
70
+ result == nil ? yield(key) : result
71
+ else
72
+ result = load(key, options || {})
73
+ result == nil ? default : result
74
+ end
75
+ end
76
+
77
+ # @param keys [Array<String>]
78
+ # @return [Array<Object, nil>] values in the order requested, nil for
79
+ # misses.
80
+ def values_at(*keys, **options)
81
+ keys.map { |key| load(key, options) }
82
+ end
83
+
84
+ # @param keys [Array<String>]
85
+ # @return [Array<Object>] values in the order requested, with the block
86
+ # result substituted for misses.
87
+ def fetch_values(*keys, **options)
88
+ values = values_at(*keys, **options)
89
+ return values unless block_given?
90
+ keys.zip(values).map do |key, value|
91
+ value == nil ? yield(key) : value
92
+ end
93
+ end
94
+
95
+ # @param keys [Array<String>]
96
+ # @return [Array<Array(String, Object)>] `[key, value]` pairs for the
97
+ # present keys only. Pairs rather than a Hash because that is what
98
+ # Moneta returns, and the point of this module is to behave the way a
99
+ # Moneta store does, not the way a Hash does.
100
+ def slice(*keys, **options)
101
+ keys.each_with_object([]) do |key, out|
102
+ value = load(key, options)
103
+ out << [key, value] unless value.nil?
104
+ end
105
+ end
106
+
107
+ # @param pairs [Hash, Enumerable] key/value pairs to write.
108
+ # @return [self] matching Moneta, which returns the store.
109
+ def merge!(pairs, options = {})
110
+ pairs.each do |key, value|
111
+ if block_given?
112
+ # The existing value is read with the SAME options the write will
113
+ # use, so a conflict block never decides against a value fetched
114
+ # under different terms than the one being stored.
115
+ existing = load(key, options)
116
+ value = yield(key, existing, value) unless existing.nil?
117
+ end
118
+ store(key, value, options)
119
+ end
120
+ self
121
+ end
122
+
123
+ alias_method :update, :merge!
124
+ end
125
+ end
126
+ end
@@ -36,15 +36,37 @@ module Parse
36
36
  end
37
37
 
38
38
  def [](key)
39
- @pool.with { |store| store[key] }
39
+ load(key, {})
40
40
  end
41
41
 
42
- def key?(key)
43
- @pool.with { |store| store.key?(key) }
42
+ # Moneta's read primitive, carrying its options argument (`expires:` to
43
+ # refresh a TTL on read, for instance).
44
+ #
45
+ # Feature-detected rather than called blind. Every real Moneta store
46
+ # implements `load`, but a custom store that only implements `[]` would
47
+ # otherwise reach `Kernel#load`, which is private (so the failure is a
48
+ # confusing NoMethodError) and, were it public, would try to load a
49
+ # FILE named after the cache key. `respond_to?` answers false for the
50
+ # private Kernel method, so this check is exact.
51
+ def load(key, options = {})
52
+ @pool.with do |store|
53
+ next store.load(key, options || {}) if store.respond_to?(:load)
54
+ store[key]
55
+ end
44
56
  end
45
57
 
46
- def delete(key)
47
- @pool.with { |store| store.delete(key) }
58
+ def key?(key, options = {})
59
+ @pool.with do |store|
60
+ next store.key?(key, options || {}) if options_aware?(store, :key?)
61
+ store.key?(key)
62
+ end
63
+ end
64
+
65
+ def delete(key, options = {})
66
+ @pool.with do |store|
67
+ next store.delete(key, options || {}) if options_aware?(store, :delete)
68
+ store.delete(key)
69
+ end
48
70
  end
49
71
 
50
72
  def store(key, value, options = {})
@@ -78,6 +100,27 @@ module Parse
78
100
  # calls are no-ops. `ConnectionPool#shutdown` raises
79
101
  # `ConnectionPool::PoolShuttingDownError` on a second invocation,
80
102
  # so we gate it with a `@closed` flag.
103
+ # Whether `store` takes Moneta's options argument for `name`.
104
+ #
105
+ # Decided from arity and memoized per method, not by calling with
106
+ # options and rescuing ArgumentError. That retry had three problems: a
107
+ # store's own argument validation also raises ArgumentError, so a
108
+ # genuine rejection was retried instead of surfaced; for `delete` the
109
+ # retry meant the deletion could run TWICE; and because each attempt
110
+ # took its own checkout, the second ran against a DIFFERENT connection
111
+ # than the first. Every pooled store is built by the same block, so one
112
+ # probe answers for all of them.
113
+ def options_aware?(store, name)
114
+ @options_aware ||= {}
115
+ return @options_aware[name] if @options_aware.key?(name)
116
+ arity = begin
117
+ store.method(name).arity
118
+ rescue NameError
119
+ 0
120
+ end
121
+ @options_aware[name] = arity.negative? || arity >= 2
122
+ end
123
+
81
124
  def close
82
125
  return if @closed
83
126
  @closed = true