typed_cache 0.1.1 → 0.3.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 (87) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/README.md +139 -20
  4. data/examples.md +140 -50
  5. data/lib/typed_cache/backends/active_support.rb +50 -5
  6. data/lib/typed_cache/backends/memory.rb +14 -11
  7. data/lib/typed_cache/backends.rb +6 -8
  8. data/lib/typed_cache/cache_builder.rb +72 -19
  9. data/lib/typed_cache/cache_key.rb +11 -1
  10. data/lib/typed_cache/cache_ref.rb +20 -16
  11. data/lib/typed_cache/clock.rb +31 -14
  12. data/lib/typed_cache/decorator.rb +25 -0
  13. data/lib/typed_cache/decorators/instrumented.rb +92 -0
  14. data/lib/typed_cache/decorators.rb +7 -3
  15. data/lib/typed_cache/either.rb +22 -0
  16. data/lib/typed_cache/errors.rb +9 -1
  17. data/lib/typed_cache/instrumenter.rb +43 -0
  18. data/lib/typed_cache/instrumenters/active_support.rb +28 -0
  19. data/lib/typed_cache/instrumenters/mixins/namespaced_singleton.rb +55 -0
  20. data/lib/typed_cache/instrumenters/mixins.rb +8 -0
  21. data/lib/typed_cache/instrumenters/monitor.rb +27 -0
  22. data/lib/typed_cache/instrumenters/null.rb +26 -0
  23. data/lib/typed_cache/instrumenters.rb +39 -0
  24. data/lib/typed_cache/maybe.rb +18 -0
  25. data/lib/typed_cache/namespace.rb +33 -6
  26. data/lib/typed_cache/railtie.rb +15 -0
  27. data/lib/typed_cache/registry.rb +15 -0
  28. data/lib/typed_cache/snapshot.rb +18 -10
  29. data/lib/typed_cache/store.rb +50 -15
  30. data/lib/typed_cache/version.rb +1 -1
  31. data/lib/typed_cache.rb +34 -14
  32. data/rbi/typed_cache/backend.rbi +9 -0
  33. data/rbi/typed_cache/backends/active_support.rbi +13 -0
  34. data/rbi/typed_cache/backends/memory.rbi +13 -0
  35. data/rbi/typed_cache/backends.rbi +19 -0
  36. data/rbi/typed_cache/cache_builder.rbi +23 -0
  37. data/rbi/typed_cache/cache_key.rbi +16 -0
  38. data/rbi/typed_cache/cache_ref.rbi +56 -0
  39. data/rbi/typed_cache/decorator.rbi +67 -0
  40. data/rbi/typed_cache/decorators/instrumented.rbi +13 -0
  41. data/rbi/typed_cache/decorators.rbi +19 -0
  42. data/rbi/typed_cache/either.rbi +122 -0
  43. data/rbi/typed_cache/errors.rbi +20 -0
  44. data/rbi/typed_cache/instrumenter.rbi +45 -0
  45. data/rbi/typed_cache/instrumenters/mixins/namedspaced_singleton.rbi +33 -0
  46. data/rbi/typed_cache/instrumenters.rbi +19 -0
  47. data/rbi/typed_cache/maybe.rbi +108 -0
  48. data/rbi/typed_cache/namespace.rbi +30 -0
  49. data/rbi/typed_cache/snapshot.rbi +54 -0
  50. data/rbi/typed_cache/store.rbi +71 -0
  51. data/rbi/typed_cache/version.rbi +5 -0
  52. data/rbi/typed_cache.rbi +49 -0
  53. data/sig/generated/typed_cache/backends/active_support.rbs +14 -2
  54. data/sig/generated/typed_cache/backends/memory.rbs +2 -2
  55. data/sig/generated/typed_cache/backends.rbs +2 -0
  56. data/sig/generated/typed_cache/cache_builder.rbs +13 -2
  57. data/sig/generated/typed_cache/cache_key.rbs +5 -0
  58. data/sig/generated/typed_cache/cache_ref.rbs +4 -4
  59. data/sig/generated/typed_cache/clock.rbs +19 -9
  60. data/sig/generated/typed_cache/decorator.rbs +12 -0
  61. data/sig/generated/typed_cache/decorators/instrumented.rbs +35 -0
  62. data/sig/generated/typed_cache/decorators.rbs +2 -0
  63. data/sig/generated/typed_cache/either.rbs +24 -0
  64. data/sig/generated/typed_cache/errors.rbs +2 -0
  65. data/sig/generated/typed_cache/instrumenter.rbs +31 -0
  66. data/sig/generated/typed_cache/instrumenters/active_support.rbs +20 -0
  67. data/sig/generated/typed_cache/instrumenters/mixins/namespaced_singleton.rbs +36 -0
  68. data/sig/generated/typed_cache/instrumenters/mixins.rbs +8 -0
  69. data/sig/generated/typed_cache/instrumenters/monitor.rbs +19 -0
  70. data/sig/generated/typed_cache/instrumenters/null.rbs +21 -0
  71. data/sig/generated/typed_cache/instrumenters.rbs +26 -0
  72. data/sig/generated/typed_cache/maybe.rbs +20 -0
  73. data/sig/generated/typed_cache/namespace.rbs +24 -3
  74. data/sig/generated/typed_cache/railtie.rbs +6 -0
  75. data/sig/generated/typed_cache/registry.rbs +8 -0
  76. data/sig/generated/typed_cache/snapshot.rbs +12 -6
  77. data/sig/generated/typed_cache/store/instrumented.rbs +2 -6
  78. data/sig/generated/typed_cache/store.rbs +26 -8
  79. data/sig/generated/typed_cache.rbs +8 -6
  80. data/typed_cache.gemspec +5 -4
  81. data.tar.gz.sig +0 -0
  82. metadata +48 -27
  83. metadata.gz.sig +0 -0
  84. data/lib/typed_cache/instrumentation.rb +0 -112
  85. data/lib/typed_cache/store/instrumented.rb +0 -83
  86. data/sig/generated/typed_cache/instrumentation.rbs +0 -30
  87. data/sig/handwritten/gems/zeitwerk/2.7/zeitwerk.rbs +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9beff9345d3d58fb92c4cb77467271af9f8bc1212529dd7d6d21d13fa4e285ad
4
- data.tar.gz: 0aef7f30f5f5029e8667944c4b6724694f5cf8e1bae1a7358a4c0a6c0931902b
3
+ metadata.gz: 52137d39af663110c085558e5d6885fbe22d02b4a185f7a1ef3f4aa2dbe6a619
4
+ data.tar.gz: 030bf378a6effe782ab28fdb876c64c73d8997e5a3a9ec543735abe55973e421
5
5
  SHA512:
6
- metadata.gz: a848cd77e148388fc0624acdd2f5b5826c573fc418d13dc15ce1f7b75acf365d8860cff1ceb123bb0b3b8f374fba100e25b5d818f51b66ac3aea0f3e6c6fe745
7
- data.tar.gz: 9e0929925806d7449462bb70deb7773eb19301d268cb93fc0fb321ca5ff38619c724f9e305c40e255a21a3b85e44fd80d4bed69ac97d13f4385af878a25c9f8d
6
+ metadata.gz: 38641da2ef43033f286833d84492b02f09dcfca291e7eab53c93bf6c9f737de5f077984a4f742de06c62a0c90db19d00bbc03cb032485143e9a542576fb5f90c
7
+ data.tar.gz: 2fb8cd40d84bb43a3f9905ccc7c0d30d853cdf0b030cf192a6e17517545ac94f256a3371f85bcd068e4f08d12c7a32a937f8eb3a87622b8b3a16429e772bd8bb
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # TypedCache
2
2
 
3
+ ![Gem Version](https://img.shields.io/gem/v/typed_cache?style=flat-square&logo=rubygems)
4
+ ![GitHub Release Date](https://img.shields.io/github/release-date/glossawy/typed_cache?style=flat-square&label=released&logo=semanticrelease)
5
+ ![GitHub last commit](https://img.shields.io/github/last-commit/glossawy/typed_cache?style=flat-square&logo=git)
6
+
7
+ ![GitHub License](https://img.shields.io/github/license/glossawy/typed_cache?style=flat-square&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNy4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjUgRm9udGljb25zLCBJbmMuLS0%2BPHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTM4NCAzMmwxMjggMGMxNy43IDAgMzIgMTQuMyAzMiAzMnMtMTQuMyAzMi0zMiAzMkwzOTguNCA5NmMtNS4yIDI1LjgtMjIuOSA0Ny4xLTQ2LjQgNTcuM0wzNTIgNDQ4bDE2MCAwYzE3LjcgMCAzMiAxNC4zIDMyIDMycy0xNC4zIDMyLTMyIDMybC0xOTIgMC0xOTIgMGMtMTcuNyAwLTMyLTE0LjMtMzItMzJzMTQuMy0zMiAzMi0zMmwxNjAgMCAwLTI5NC43Yy0yMy41LTEwLjMtNDEuMi0zMS42LTQ2LjQtNTcuM0wxMjggOTZjLTE3LjcgMC0zMi0xNC4zLTMyLTMyczE0LjMtMzIgMzItMzJsMTI4IDBjMTQuNi0xOS40IDM3LjgtMzIgNjQtMzJzNDkuNCAxMi42IDY0IDMyem01NS42IDI4OGwxNDQuOSAwTDUxMiAxOTUuOCA0MzkuNiAzMjB6TTUxMiA0MTZjLTYyLjkgMC0xMTUuMi0zNC0xMjYtNzguOWMtMi42LTExIDEtMjIuMyA2LjctMzIuMWw5NS4yLTE2My4yYzUtOC42IDE0LjItMTMuOCAyNC4xLTEzLjhzMTkuMSA1LjMgMjQuMSAxMy44bDk1LjIgMTYzLjJjNS43IDkuOCA5LjMgMjEuMSA2LjcgMzIuMUM2MjcuMiAzODIgNTc0LjkgNDE2IDUxMiA0MTZ6TTEyNi44IDE5NS44TDU0LjQgMzIwbDE0NC45IDBMMTI2LjggMTk1Ljh6TS45IDMzNy4xYy0yLjYtMTEgMS0yMi4zIDYuNy0zMi4xbDk1LjItMTYzLjJjNS04LjYgMTQuMi0xMy44IDI0LjEtMTMuOHMxOS4xIDUuMyAyNC4xIDEzLjhsOTUuMiAxNjMuMmM1LjcgOS44IDkuMyAyMS4xIDYuNyAzMi4xQzI0MiAzODIgMTg5LjcgNDE2IDEyNi44IDQxNlMxMS43IDM4MiAuOSAzMzcuMXoiLz48L3N2Zz4%3D)
8
+
3
9
  TypedCache is a lightweight, type-safe façade around your favourite Ruby cache
4
10
  stores. It adds three things on top of the raw back-end implementation:
5
11
 
6
- 1. **Namespacing** – hierarchical `Namespace` helpers prevent key collisions.
12
+ 1. **Namespacing** – hierarchical `Namespace` helpers prevent key collisions. You can create nested namespaces easily, like `Namespace.at("users", "profiles", "avatars")`.
7
13
  2. **Stronger types** – RBS signatures as well as monadic types like `Either`, `Maybe`, and `Snapshot` wrap cache results so you always know whether you have a value, an error, or a cache-miss.
8
14
  3. **Composable decorators** – behaviours like instrumentation can be layered
9
15
  on without touching the underlying store.
@@ -35,26 +41,33 @@ If there are issues with unsigned gems, use `MediumSecurity` instead.
35
41
  ```ruby
36
42
  require "typed_cache"
37
43
 
38
- # Build an in-memory cache with ActiveSupport-style instrumentation
44
+ # 1. Build a store
39
45
  store = TypedCache.builder
40
- .with_backend(:memory, shared: true)
41
- .with_instrumentation # or .with_decorator(:instrumented)
42
- .build # => Either[Error, Store]
43
- .value
44
-
45
- users_key = store.namespace.key("users") # => CacheKey
46
- snapshot = store.set(users_key, [1, 2, 3]) # => Either[Error, Snapshot]
47
- puts snapshot.value # => [1, 2, 3]
46
+ .with_backend(:memory, shared: true)
47
+ .with_instrumentation(:rails) # e.g. using ActiveSupport
48
+ .build
49
+ .value # unwrap Either for brevity
50
+
51
+ # 2. Get a reference to a key
52
+ user_ref = store.ref("users:123") # => CacheRef
53
+
54
+ # 3. Fetch and compute if absent
55
+ user_snapshot = user_ref.fetch do
56
+ puts "Cache miss! Computing..."
57
+ { id: 123, name: "Jane" }
58
+ end.value # => Snapshot
59
+
60
+ puts "Found: #{user_snapshot.value} (from_cache?=#{user_snapshot.from_cache?})"
48
61
  ```
49
62
 
50
63
  ## Builder API
51
64
 
52
- | Step | Purpose |
53
- | ----------------------------- | -------------------------------------------------------------- |
54
- | `with_backend(:name, **opts)` | Mandatory. Configure the concrete **Backend** and its options. |
55
- | `with_decorator(:key)` | Optional. Add a decorator by registry key. |
56
- | `with_instrumentation` | Convenience alias for `with_decorator(:instrumented)`. |
57
- | `build` | Returns `Either[Error, Store]`. |
65
+ | Step | Purpose |
66
+ | ------------------------------- | -------------------------------------------------------------- |
67
+ | `with_backend(:name, **opts)` | Mandatory. Configure the concrete **Backend** and its options. |
68
+ | `with_decorator(:key)` | Optional. Add a decorator by registry key. |
69
+ | `with_instrumentation(:source)` | Optional. Add instrumentation, e.g. `:rails` or `:dry`. |
70
+ | `build` | Returns `Either[Error, Store]`. |
58
71
 
59
72
  ### Back-ends vs Decorators
60
73
 
@@ -112,16 +125,122 @@ result.fold(
112
125
  )
113
126
  ```
114
127
 
128
+ ## The `CacheRef` and `Store` APIs
129
+
130
+ While you can call `get`, `set`, and `fetch` directly on the `store`, the more powerful way to work with TypedCache is via the `CacheRef` object. It provides a rich, monadic API for a single cache key. The `Store` also provides `fetch_all` for batch operations.
131
+
132
+ You get a `CacheRef` by calling `store.ref(key)`:
133
+
134
+ ```ruby
135
+ user_ref = store.ref("users:123") # => #<TypedCache::CacheRef ...>
136
+ ```
137
+
138
+ Now you can operate on it:
139
+
140
+ ```ruby
141
+ # Fetch a value, computing it if it's missing
142
+ snapshot_either = user_ref.fetch do
143
+ { id: 123, name: "Jane Doe" }
144
+ end
145
+
146
+ # The result is always an Either[Error, Snapshot]
147
+ snapshot_either.fold(
148
+ ->(err) { warn "Something went wrong: #{err.message}" },
149
+ ->(snapshot) { puts "Got value: #{snapshot.value} (from cache: #{snapshot.from_cache?})" }
150
+ )
151
+
152
+ # You can also map over values
153
+ name_either = user_ref.map { |user| user[:name] }
154
+ puts "User name is: #{name_either.value.value}" # unwrap Either, then Snapshot
155
+ ```
156
+
157
+ ### Batch Operations with `fetch_all`
158
+
159
+ For retrieving multiple keys at once, the `Store` provides a `fetch_all` method. This is more efficient than fetching keys one by one, especially with remote back-ends like Redis.
160
+
161
+ It takes a list of keys and a block to compute the values for any missing keys.
162
+
163
+ ```ruby
164
+ user_refs = store.fetch_all("users:123", "users:456") do |missing_key|
165
+ # This block is called for each cache miss
166
+ user_id = missing_key.split(":").last
167
+ puts "Cache miss for #{missing_key}! Computing..."
168
+ { id: user_id, name: "Fetched User #{user_id}" }
169
+ end
170
+
171
+ user_refs.each do |key, snapshot_either|
172
+ snapshot_either.fold(
173
+ ->(err) { warn "Error for #{key}: #{err.message}" },
174
+ ->(snapshot) { puts "Got value for #{key}: #{snapshot.value}" }
175
+ )
176
+ end
177
+ ```
178
+
179
+ The `CacheRef` API encourages a functional style and makes composing cache operations safe and predictable.
180
+
115
181
  ## Instrumentation
116
182
 
117
- Decorators publish ActiveSupport notifications when
118
- `TypedCache.config.instrumentation.enabled = true`:
183
+ TypedCache can publish events about cache operations using different instrumenters. To enable it, use the `with_instrumentation` method on the builder, specifying an instrumentation backend:
119
184
 
185
+ ```ruby
186
+ # For ActiveSupport::Notifications (e.g. in Rails)
187
+ store = TypedCache.builder
188
+ .with_backend(:memory)
189
+ .with_instrumentation(:rails)
190
+ .build.value
191
+
192
+ # For Dry::Monitor
193
+ store = TypedCache.builder
194
+ .with_backend(:memory)
195
+ .with_instrumentation(:dry)
196
+ .build.value
120
197
  ```
121
- <operation>.<namespace> # e.g. get.typed_cache
198
+
199
+ Events are published to a topic like `typed_cache.<operation>` (e.g., `typed_cache.get`). The topic namespace can be configured.
200
+
201
+ Payload keys include: `:namespace, :key, :operation, :duration`, and `cache_hit`.
202
+
203
+ You can subscribe to these events like so:
204
+
205
+ ```ruby
206
+ # Example for ActiveSupport
207
+ ActiveSupport::Notifications.subscribe("typed_cache.get") do |name, start, finish, id, payload|
208
+
209
+ # Or you can subscribe via the store object itself
210
+ instrumenter = store.instrumenter
211
+ instrumenter.subscribe("get") do |event|
212
+ payload = event.payload
213
+ puts "Cache GET for key #{payload[:key]} took #{payload[:duration]}ms. Hit? #{payload[:cache_hit]}"
214
+ end
122
215
  ```
123
216
 
124
- Payload keys: `:namespace, :key, :duration, :cache_hit`,
217
+ If you call `with_instrumentation` with no arguments, it uses a `Null` instrumenter, which has no overhead.
218
+
219
+ ### Custom Instrumenters
220
+
221
+ Just like with back-ends and decorators, you can write and register your own instrumenters. An instrumenter must implement an `instrument` and a `subscribe` method.
222
+
223
+ ```ruby
224
+ class MyCustomInstrumenter
225
+ include TypedCache::Instrumenter
226
+
227
+ def instrument(operation, key, **payload, &block)
228
+ # ... your logic ...
229
+ end
230
+
231
+ def subscribe(event_name, **filters, &block)
232
+ # ... your logic ...
233
+ end
234
+ end
235
+
236
+ # Register it
237
+ TypedCache::Instrumenters.register(:custom, MyCustomInstrumenter)
238
+
239
+ # Use it
240
+ store = TypedCache.builder
241
+ .with_instrumentation(:custom)
242
+ # ...
243
+ ```
125
244
 
126
245
  ## Further examples
127
246
 
data/examples.md CHANGED
@@ -7,16 +7,19 @@ This document provides practical examples of using TypedCache in real applicatio
7
7
  The simplest way to create a type-safe cache:
8
8
 
9
9
  ```ruby
10
- user_namespace = TypedCache::Namespace.at("users")
11
-
12
- cache_result = TypedCache.builder
10
+ # The builder can be pre-configured and reused
11
+ builder = TypedCache.builder
13
12
  .with_backend(:memory, shared: true)
14
- .with_instrumentation
15
- .build(user_namespace) # => Either[Error, Store]
13
+ .with_instrumentation(:rails)
14
+
15
+ # The store is built with a namespace
16
+ users_store = builder.build(TypedCache::Namespace.at("users")).value
16
17
 
17
- store = cache_result.value # unwrap for brevity
18
- key = store.namespace.key("123") # => CacheKey
19
- store.set(key, { id: 123, name: "Jane" })
18
+ # Get a reference to a key
19
+ user_ref = users_store.ref("123")
20
+
21
+ # Set a value
22
+ user_ref.set({ id: 123, name: "Jane" })
20
23
  ```
21
24
 
22
25
  ## Rails Integration
@@ -24,13 +27,17 @@ store.set(key, { id: 123, name: "Jane" })
24
27
  Using TypedCache with `Rails.cache` and ActiveSupport notifications:
25
28
 
26
29
  ```ruby
27
- cache_result = TypedCache.builder
30
+ # Use Rails.cache as the backend
31
+ builder = TypedCache.builder
28
32
  .with_backend(:active_support, Rails.cache)
29
- .with_instrumentation
30
- .build # defaults to TypedCache.config.default_namespace
33
+ .with_instrumentation(:rails)
31
34
 
32
- cache = cache_result.value
33
- cache.fetch(cache.namespace.key("header")) { render_header }
35
+ # Build a store for a specific part of your app
36
+ header_store = builder.build(TypedCache::Namespace.at("views:header")).value
37
+
38
+ # Use a ref to fetch/render
39
+ header_ref = header_store.ref("main")
40
+ header_html = header_ref.fetch { render_header }.value.value
34
41
  ```
35
42
 
36
43
  ## Pattern Matching
@@ -58,35 +65,106 @@ Reuse a preconfigured builder for several namespaces:
58
65
  base_builder = TypedCache.builder
59
66
  .with_backend(:memory, shared: true)
60
67
 
61
- users_store = base_builder.build(TypedCache::Namespace.at("users")).value
62
- posts_store = base_builder.build(TypedCache::Namespace.at("posts")).value
68
+ users_store = base_builder.build(TypedCache::Namespace.at("users")).value
69
+ posts_store = base_builder.build(TypedCache::Namespace.at("posts")).value
63
70
  comments_store = base_builder.build(TypedCache::Namespace.at("comments")).value
64
71
  ```
65
72
 
66
- ## Cache Operations
73
+ ## Advanced Namespacing
67
74
 
68
- Working with cache values using the monadic interface:
75
+ You can create nested namespaces using variadic arguments to `Namespace.at` or by chaining `join`.
69
76
 
70
77
  ```ruby
71
- # Set a value
72
- cache.set({ id: 1, name: "John" })
78
+ # These are equivalent
79
+ ns1 = TypedCache::Namespace.at("app", "v1", "users")
80
+ ns2 = TypedCache::Namespace.at("app").join("v1").join("users")
81
+
82
+ puts ns1.to_s # => "app:v1:users"
83
+ puts ns2.to_s # => "app:v1:users"
84
+
85
+ # You can then build a store with this complex namespace
86
+ user_store = base_builder.build(ns1).value
87
+ user_store.ref("123").set({ name: "Deeply Nested" })
88
+ ```
89
+
90
+ ## CacheRef and Store APIs
91
+
92
+ The `CacheRef` is the most powerful way to interact with a single cache key, while the `Store` provides batch operations.
93
+
94
+ ```ruby
95
+ ref = store.ref("some-key") # => CacheRef
96
+ ```
97
+
98
+ ### Get a value
73
99
 
74
- # Get with error handling
75
- cache.get.fold(
76
- ->(error) { puts "Cache miss: #{error.message}" },
77
- ->(snapshot) { puts "Found: #{snapshot.value}" }
100
+ The `get` method returns an `Either[Error, Snapshot]`.
101
+
102
+ ```ruby
103
+ result = ref.get
104
+ result.fold(
105
+ ->(error) { puts "Cache miss or error: #{error.message}" },
106
+ ->(snapshot) { puts "Found: #{snapshot.value} (from cache: #{snapshot.from_cache?})" }
78
107
  )
108
+ ```
109
+
110
+ ### Fetch all (get or compute)
111
+
112
+ The `fetch_all` method on the `store` is used for bulk-retrieving items. It gets all existing values from the cache and for the ones that are missing, it runs the block, stores the result, and returns it.
113
+
114
+ ```ruby
115
+ results = store.fetch_all("user:1", "user:2") do |missing_key|
116
+ # logic to compute the value for a missing key
117
+ "computed-#{missing_key}"
118
+ end
119
+
120
+ results.each do |key, snapshot_either|
121
+ snapshot_either.map do |snapshot|
122
+ puts "#{key} -> #{snapshot.value} (from_cache?=#{snapshot.from_cache?})"
123
+ end
124
+ end
125
+ ```
126
+
127
+ ### Fetch (get or compute)
128
+
129
+ The `fetch` method is the most common operation. It gets a value from the cache, but if it's missing, it runs the block, stores the result, and returns it.
130
+
131
+ ```ruby
132
+ user = ref.fetch { expensive_user_lookup(123) }.value.value
133
+ ```
134
+
135
+ ### Mapping values
136
+
137
+ You can transform the value inside the cache reference without breaking the monadic chain.
138
+
139
+ ```ruby
140
+ # user_ref holds { id: 1, name: "John" }
141
+ name_ref = user_ref.map { |user| user[:name] }
142
+
143
+ name_snapshot = name_ref.get.value # => Snapshot(value: "John", ...)
144
+ ```
145
+
146
+ ### Chaining operations with `bind`
79
147
 
80
- # Get with Maybe semantics (no error details)
81
- user = cache.peek.value_or({ id: 0, name: "Anonymous" })
148
+ For more complex logic, you can use `bind` (or `flat_map`) to chain operations that return an `Either`.
82
149
 
83
- # Fetch with computation
84
- result = cache.fetch do
85
- # This block runs only on cache miss
86
- expensive_user_lookup(user_id)
150
+ ```ruby
151
+ user_ref.bind do |user|
152
+ if user.active?
153
+ posts_ref.set(user.posts)
154
+ else
155
+ TypedCache::Either.left(StandardError.new("User is not active"))
156
+ end
87
157
  end
88
158
  ```
89
159
 
160
+ ### Getting the value or a default
161
+
162
+ If you just want the value and don't care about the `Snapshot` metadata, you can use `value_or`.
163
+
164
+ ```ruby
165
+ user_name = user_ref.map { |u| u[:name] }.value_or("Anonymous")
166
+ ```
167
+
90
168
  ## Custom Backend
91
169
 
92
170
  Registering and using a custom cache backend:
@@ -126,35 +204,47 @@ cache = TypedCache.builder
126
204
  .build.value
127
205
  ```
128
206
 
129
- ## Instrumentation Only
207
+ ## Custom Instrumenter
208
+
209
+ You can create and register your own instrumenter to integrate with any monitoring or logging system. An instrumenter needs to implement `instrument` and `subscribe` methods.
210
+
211
+ Here's an example of a simple logging instrumenter:
130
212
 
131
213
  ```ruby
132
- TypedCache.configure_instrumentation do |config|
133
- config.enabled = true
134
- config.namespace = "my_app_cache"
214
+ class LoggingInstrumenter
215
+ include TypedCache::Instrumenter
216
+ include TypedCache::Instrumenters::Mixins::NamespacedSingleton
217
+
218
+ def instrument(operation, key, **payload, &block)
219
+ puts "[CACHE] Starting: #{operation} for key #{key}"
220
+ result = block.call
221
+ puts "[CACHE] Finished: #{operation} for key #{key}"
222
+ result
223
+ end
224
+
225
+ def subscribe(operation, **_filters, &block)
226
+ # For simplicity, this example doesn't implement a full subscription model,
227
+ # but in a real-world scenario, you would store and manage callbacks here.
228
+ puts "[CACHE] Subscribed to '#{operation}'"
229
+ end
135
230
  end
136
231
 
137
- cache = TypedCache.builder
232
+ # Register the new instrumenter
233
+ TypedCache::Instrumenters.register(:logger, LoggingInstrumenter)
234
+
235
+ # Use it in the builder
236
+ logging_cache = TypedCache.builder
138
237
  .with_backend(:memory)
139
- .with_instrumentation
238
+ .with_instrumentation(:logger)
140
239
  .build.value
141
240
 
142
- cache.set(cache.namespace.key("metrics"), 42)
143
- ```
241
+ # Subscribe to an event
242
+ logging_cache.instrumenter.subscribe("get")
144
243
 
145
- ## Configuration Snippet
146
-
147
- ```ruby
148
- TypedCache.configure do |config|
149
- config.default_namespace = "my_app"
150
- end
151
- ```
152
-
153
- You can register additional decorators or back-ends as needed:
154
-
155
- ```ruby
156
- TypedCache::Decorators.register(:logger, MyLoggerDecorator)
157
- TypedCache::Backends.register(:redis, MyRedisBackend)
244
+ # Operations will now be logged
245
+ logging_cache.ref("test").set("hello")
246
+ # => [CACHE] Starting: set for key test
247
+ # => [CACHE] Finished: set for key test
158
248
  ```
159
249
 
160
250
  ## Thread Safety
@@ -20,24 +20,24 @@ module TypedCache
20
20
 
21
21
  # @rbs override
22
22
  #: (cache_key) -> either[Error, Snapshot[V]]
23
- def get(key)
23
+ def read(key)
24
24
  cache_key_str = namespaced_key(key).to_s
25
25
  raw_value = cache_store.read(cache_key_str, default_options)
26
26
  return Either.left(CacheMissError.new(key)) if raw_value.nil?
27
27
 
28
- Either.right(Snapshot.new(raw_value, source: :cache))
28
+ Either.right(Snapshot.cached(key, raw_value))
29
29
  rescue => e
30
30
  Either.left(StoreError.new(:get, key, "Failed to read from cache: #{e.message}", e))
31
31
  end
32
32
 
33
33
  # @rbs override
34
34
  #: (cache_key, V) -> either[Error, Snapshot[V]]
35
- def set(key, value)
35
+ def write(key, value)
36
36
  cache_key_str = namespaced_key(key).to_s
37
37
  success = cache_store.write(cache_key_str, value, default_options)
38
38
 
39
39
  if success
40
- Either.right(Snapshot.new(value, source: :cache))
40
+ Either.right(Snapshot.cached(key, value))
41
41
  else
42
42
  Either.left(StoreError.new(:set, key, 'Failed to write to cache', nil))
43
43
  end
@@ -45,10 +45,19 @@ module TypedCache
45
45
  Either.left(StoreError.new(:set, key, "Failed to write to cache: #{e.message}", e))
46
46
  end
47
47
 
48
+ # @rbs override
49
+ #: (Hash[cache_key, V]) -> either[Error, Array[Snapshot[V]]]
50
+ def write_all(values)
51
+ results = cache_store.write_multi(values.map { |key, value| [namespaced_key(key).to_s, value] }.to_h, default_options)
52
+ Either.right(results.map { |key, value| Snapshot.cached(key, value) })
53
+ rescue => e
54
+ Either.left(StoreError.new(:set_all, values, "Failed to write to cache: #{e.message}", e))
55
+ end
56
+
48
57
  # @rbs override
49
58
  #: (cache_key) -> either[Error, Snapshot[V]]
50
59
  def delete(key)
51
- get(key).fold(
60
+ read(key).fold(
52
61
  ->(error) { Either.left(error) },
53
62
  ->(snapshot) {
54
63
  cache_key_str = namespaced_key(key).to_s
@@ -60,6 +69,42 @@ module TypedCache
60
69
  Either.left(StoreError.new(:delete, key, "Failed to delete from cache: #{e.message}", e))
61
70
  end
62
71
 
72
+ # @rbs override
73
+ #: (Array[cache_key]) -> either[Error, Array[Snapshot[V]]]
74
+ def read_all(keys)
75
+ results = cache_store.read_multi(*keys.map { |key| namespaced_key(key).to_s }, default_options)
76
+ Either.right(results.map { |key, value| [key, Snapshot.cached(key, value)] }.to_h)
77
+ end
78
+
79
+ # @rbs override
80
+ #: (Array[cache_key]) { (CacheKey) -> V? } -> either[Error, Array[Snapshot[V]]]
81
+ def fetch_all(keys, &block)
82
+ cache_keys = keys.map { |key| namespaced_key(key) }
83
+ key_map = cache_keys.index_by(&:to_s)
84
+
85
+ computed_keys = Set.new #: Set[String]
86
+ results = cache_store.fetch_multi(*key_map.keys, default_options) do |key|
87
+ computed_keys << key
88
+ yield(key_map[key])
89
+ end
90
+
91
+ snapshots = [] #: Array[Snapshot[V]]
92
+
93
+ results.each do |key, value|
94
+ maybe_value = Maybe.wrap(value)
95
+ snapshots <<
96
+ if computed_keys.include?(key)
97
+ Snapshot.computed(key, maybe_value)
98
+ else
99
+ Snapshot.cached(key, maybe_value)
100
+ end
101
+ end
102
+
103
+ Either.right(snapshots)
104
+ rescue StandardError => e
105
+ Either.left(StoreError.new(:fetch_all, keys, "Failed to fetch from cache: #{e.message}", e))
106
+ end
107
+
63
108
  # @rbs override
64
109
  #: (cache_key) -> bool
65
110
  def key?(key)
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'singleton'
4
- require 'forwardable'
4
+
5
+ require 'concurrent/map'
5
6
 
6
7
  module TypedCache
7
8
  class MemoryStoreRegistry
@@ -42,14 +43,16 @@ module TypedCache
42
43
  # @rbs! attr_accessor expires_at: Time
43
44
  # @rbs! attr_reader value: V
44
45
 
45
- # @rbs (value: V, expires_in: Integer) -> Entry[V]
46
- def self.expiring(value:, expires_in:)
47
- new(value: value, expires_at: Clock.moment + expires_in)
46
+ class << self
47
+ # @rbs (value: V, expires_in: Integer) -> Entry[V]
48
+ def expiring(value:, expires_in:)
49
+ new(value: value, expires_at: Clock.moment + expires_in)
50
+ end
48
51
  end
49
52
 
50
53
  # @rbs () -> bool
51
54
  def expired?
52
- Clock.moment >= expires_at
55
+ Clock.now >= expires_at
53
56
  end
54
57
  end
55
58
  private_constant :Entry
@@ -66,7 +69,7 @@ module TypedCache
66
69
 
67
70
  # @rbs override
68
71
  #: (cache_key) -> either[Error, Snapshot[V]]
69
- def get(key)
72
+ def read(key)
70
73
  key = namespaced_key(key)
71
74
  return Either.left(CacheMissError.new(key)) unless backing_store.key?(key)
72
75
 
@@ -77,17 +80,17 @@ module TypedCache
77
80
  return Either.left(CacheMissError.new(key))
78
81
  end
79
82
 
80
- Either.right(Snapshot.new(entry.value, source: :cache))
83
+ Either.right(Snapshot.cached(key, entry.value))
81
84
  end
82
85
 
83
86
  # @rbs override
84
87
  #: (cache_key, V) -> either[Error, Snapshot[V]]
85
- def set(key, value)
88
+ def write(key, value)
86
89
  key = namespaced_key(key)
87
- expires_at = Clock.moment + @ttl
90
+ expires_at = Clock.now + @ttl
88
91
  entry = Entry.new(value: value, expires_at: expires_at)
89
92
  backing_store[key] = entry
90
- Either.right(Snapshot.new(value, source: :cache))
93
+ Either.right(Snapshot.cached(key, value))
91
94
  rescue => e
92
95
  Either.left(StoreError.new(
93
96
  :set,
@@ -105,7 +108,7 @@ module TypedCache
105
108
  if entry.nil?
106
109
  Either.left(CacheMissError.new(key))
107
110
  else
108
- Either.right(Snapshot.new(entry.value, source: :cache))
111
+ Either.right(Snapshot.cached(key, entry.value))
109
112
  end
110
113
  end
111
114
 
@@ -1,25 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/struct'
4
- require 'dry/types'
5
-
6
- require_relative 'backends/memory'
7
- require_relative 'backends/active_support'
3
+ require 'typed_cache/registry'
8
4
 
9
5
  module TypedCache
10
6
  module Backends
7
+ autoload :Memory, 'typed_cache/backends/memory'
8
+ autoload :ActiveSupport, 'typed_cache/backends/active_support'
9
+
10
+ # @api private
11
11
  # Backend registry using composition
12
12
  REGISTRY = Registry.new('backend', {
13
13
  memory: Memory,
14
- active_support: ActiveSupport,
15
14
  }).freeze
16
15
 
17
- private_constant :REGISTRY
18
-
19
16
  class << self
20
17
  extend Forwardable
21
18
  delegate [:resolve, :register, :available, :registered?] => :registry
22
19
 
20
+ # @api private
23
21
  #: -> Registry
24
22
  def registry = REGISTRY
25
23