typed_cache 0.1.1 → 0.2.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 (50) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/README.md +116 -19
  4. data/examples.md +106 -50
  5. data/lib/typed_cache/backends/memory.rb +9 -6
  6. data/lib/typed_cache/backends.rb +6 -8
  7. data/lib/typed_cache/cache_builder.rb +72 -19
  8. data/lib/typed_cache/cache_key.rb +2 -0
  9. data/lib/typed_cache/cache_ref.rb +4 -0
  10. data/lib/typed_cache/clock.rb +31 -14
  11. data/lib/typed_cache/decorator.rb +17 -0
  12. data/lib/typed_cache/{store → decorators}/instrumented.rb +19 -23
  13. data/lib/typed_cache/decorators.rb +7 -3
  14. data/lib/typed_cache/errors.rb +9 -1
  15. data/lib/typed_cache/instrumenter.rb +43 -0
  16. data/lib/typed_cache/instrumenters/active_support.rb +28 -0
  17. data/lib/typed_cache/instrumenters/mixins/namespaced_singleton.rb +52 -0
  18. data/lib/typed_cache/instrumenters/mixins.rb +8 -0
  19. data/lib/typed_cache/instrumenters/monitor.rb +27 -0
  20. data/lib/typed_cache/instrumenters/null.rb +26 -0
  21. data/lib/typed_cache/instrumenters.rb +38 -0
  22. data/lib/typed_cache/registry.rb +15 -0
  23. data/lib/typed_cache/store.rb +3 -0
  24. data/lib/typed_cache/version.rb +1 -1
  25. data/lib/typed_cache.rb +30 -14
  26. data/sig/generated/typed_cache/backends.rbs +2 -0
  27. data/sig/generated/typed_cache/cache_builder.rbs +13 -2
  28. data/sig/generated/typed_cache/clock.rbs +19 -9
  29. data/sig/generated/typed_cache/decorator.rbs +8 -0
  30. data/sig/generated/typed_cache/decorators/instrumented.rbs +35 -0
  31. data/sig/generated/typed_cache/decorators.rbs +2 -0
  32. data/sig/generated/typed_cache/errors.rbs +2 -0
  33. data/sig/generated/typed_cache/instrumenter.rbs +31 -0
  34. data/sig/generated/typed_cache/instrumenters/active_support.rbs +20 -0
  35. data/sig/generated/typed_cache/instrumenters/mixins/namespaced_singleton.rbs +33 -0
  36. data/sig/generated/typed_cache/instrumenters/mixins.rbs +8 -0
  37. data/sig/generated/typed_cache/instrumenters/monitor.rbs +19 -0
  38. data/sig/generated/typed_cache/instrumenters/null.rbs +21 -0
  39. data/sig/generated/typed_cache/instrumenters.rbs +24 -0
  40. data/sig/generated/typed_cache/registry.rbs +8 -0
  41. data/sig/generated/typed_cache/store/instrumented.rbs +2 -6
  42. data/sig/generated/typed_cache/store.rbs +3 -0
  43. data/sig/generated/typed_cache.rbs +6 -6
  44. data/typed_cache.gemspec +4 -3
  45. data.tar.gz.sig +0 -0
  46. metadata +25 -27
  47. metadata.gz.sig +0 -0
  48. data/lib/typed_cache/instrumentation.rb +0 -112
  49. data/sig/generated/typed_cache/instrumentation.rbs +0 -30
  50. 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: 2cd862f92c635a4ee2fa15fa26186e56226d9dca6a132ad7de2f96fc9935ee31
4
+ data.tar.gz: a862c34b4e86e838e4a30bfda8149357581782d4e2c64dd6a37882724930955d
5
5
  SHA512:
6
- metadata.gz: a848cd77e148388fc0624acdd2f5b5826c573fc418d13dc15ce1f7b75acf365d8860cff1ceb123bb0b3b8f374fba100e25b5d818f51b66ac3aea0f3e6c6fe745
7
- data.tar.gz: 9e0929925806d7449462bb70deb7773eb19301d268cb93fc0fb321ca5ff38619c724f9e305c40e255a21a3b85e44fd80d4bed69ac97d13f4385af878a25c9f8d
6
+ metadata.gz: 2df948e5a9e9808c13547e940547484ad9cff0ce75085f3ee512a8ef866459dd791c7d1c85147b65dbb6ccbacea973cc412b44d3be12f7cb564c94afee452f2a
7
+ data.tar.gz: 57e1c99cb2c11a82e1b9add65357a8abacdc0260ea1ca66c6cd3616ac13db73439b24c25643eed5425a1a23fb62984bf2feb4ce854c1a54f1bcbd790248e0f2d
checksums.yaml.gz.sig CHANGED
Binary file
data/README.md CHANGED
@@ -1,5 +1,11 @@
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
 
@@ -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,100 @@ result.fold(
112
125
  )
113
126
  ```
114
127
 
128
+ ## The `CacheRef` API
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.
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
+ The `CacheRef` API encourages a functional style and makes composing cache operations safe and predictable.
158
+
115
159
  ## Instrumentation
116
160
 
117
- Decorators publish ActiveSupport notifications when
118
- `TypedCache.config.instrumentation.enabled = true`:
161
+ 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
162
 
163
+ ```ruby
164
+ # For ActiveSupport::Notifications (e.g. in Rails)
165
+ store = TypedCache.builder
166
+ .with_backend(:memory)
167
+ .with_instrumentation(:rails)
168
+ .build.value
169
+
170
+ # For Dry::Monitor
171
+ store = TypedCache.builder
172
+ .with_backend(:memory)
173
+ .with_instrumentation(:dry)
174
+ .build.value
120
175
  ```
121
- <operation>.<namespace> # e.g. get.typed_cache
176
+
177
+ Events are published to a topic like `typed_cache.<operation>` (e.g., `typed_cache.get`). The topic namespace can be configured.
178
+
179
+ Payload keys include: `:namespace, :key, :operation, :duration`, and `cache_hit`.
180
+
181
+ You can subscribe to these events like so:
182
+
183
+ ```ruby
184
+ # Example for ActiveSupport
185
+ ActiveSupport::Notifications.subscribe("typed_cache.get") do |name, start, finish, id, payload|
186
+
187
+ # Or you can subscribe via the store object itself
188
+ instrumenter = store.instrumenter
189
+ instrumenter.subscribe("get") do |event|
190
+ payload = event.payload
191
+ puts "Cache GET for key #{payload[:key]} took #{payload[:duration]}ms. Hit? #{payload[:cache_hit]}"
192
+ end
122
193
  ```
123
194
 
124
- Payload keys: `:namespace, :key, :duration, :cache_hit`,
195
+ If you call `with_instrumentation` with no arguments, it uses a `Null` instrumenter, which has no overhead.
196
+
197
+ ### Custom Instrumenters
198
+
199
+ 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.
200
+
201
+ ```ruby
202
+ class MyCustomInstrumenter
203
+ include TypedCache::Instrumenter
204
+
205
+ def instrument(operation, key, **payload, &block)
206
+ # ... your logic ...
207
+ end
208
+
209
+ def subscribe(event_name, **filters, &block)
210
+ # ... your logic ...
211
+ end
212
+ end
213
+
214
+ # Register it
215
+ TypedCache::Instrumenters.register(:custom, MyCustomInstrumenter)
216
+
217
+ # Use it
218
+ store = TypedCache.builder
219
+ .with_instrumentation(:custom)
220
+ # ...
221
+ ```
125
222
 
126
223
  ## Further examples
127
224
 
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
17
+
18
+ # Get a reference to a key
19
+ user_ref = users_store.ref("123")
16
20
 
17
- store = cache_result.value # unwrap for brevity
18
- key = store.namespace.key("123") # => CacheKey
19
- store.set(key, { id: 123, name: "Jane" })
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)
34
+
35
+ # Build a store for a specific part of your app
36
+ header_store = builder.build(TypedCache::Namespace.at("views:header")).value
31
37
 
32
- cache = cache_result.value
33
- cache.fetch(cache.namespace.key("header")) { render_header }
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,72 @@ 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
+ ## CacheRef API
67
74
 
68
- Working with cache values using the monadic interface:
75
+ The `CacheRef` is the most powerful way to interact with a cache key.
69
76
 
70
77
  ```ruby
71
- # Set a value
72
- cache.set({ id: 1, name: "John" })
78
+ ref = store.ref("some-key") # => CacheRef
79
+ ```
80
+
81
+ ### Get a value
82
+
83
+ The `get` method returns an `Either[Error, Snapshot]`.
73
84
 
74
- # Get with error handling
75
- cache.get.fold(
76
- ->(error) { puts "Cache miss: #{error.message}" },
77
- ->(snapshot) { puts "Found: #{snapshot.value}" }
85
+ ```ruby
86
+ result = ref.get
87
+ result.fold(
88
+ ->(error) { puts "Cache miss or error: #{error.message}" },
89
+ ->(snapshot) { puts "Found: #{snapshot.value} (from cache: #{snapshot.from_cache?})" }
78
90
  )
91
+ ```
92
+
93
+ ### Fetch (get or compute)
94
+
95
+ 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.
96
+
97
+ ```ruby
98
+ user = ref.fetch { expensive_user_lookup(123) }.value.value
99
+ ```
100
+
101
+ ### Mapping values
102
+
103
+ You can transform the value inside the cache reference without breaking the monadic chain.
104
+
105
+ ```ruby
106
+ # user_ref holds { id: 1, name: "John" }
107
+ name_ref = user_ref.map { |user| user[:name] }
108
+
109
+ name_snapshot = name_ref.get.value # => Snapshot(value: "John", ...)
110
+ ```
111
+
112
+ ### Chaining operations with `bind`
79
113
 
80
- # Get with Maybe semantics (no error details)
81
- user = cache.peek.value_or({ id: 0, name: "Anonymous" })
114
+ For more complex logic, you can use `bind` (or `flat_map`) to chain operations that return an `Either`.
82
115
 
83
- # Fetch with computation
84
- result = cache.fetch do
85
- # This block runs only on cache miss
86
- expensive_user_lookup(user_id)
116
+ ```ruby
117
+ user_ref.bind do |user|
118
+ if user.active?
119
+ posts_ref.set(user.posts)
120
+ else
121
+ TypedCache::Either.left(StandardError.new("User is not active"))
122
+ end
87
123
  end
88
124
  ```
89
125
 
126
+ ### Getting the value or a default
127
+
128
+ If you just want the value and don't care about the `Snapshot` metadata, you can use `value_or`.
129
+
130
+ ```ruby
131
+ user_name = user_ref.map { |u| u[:name] }.value_or("Anonymous")
132
+ ```
133
+
90
134
  ## Custom Backend
91
135
 
92
136
  Registering and using a custom cache backend:
@@ -126,35 +170,47 @@ cache = TypedCache.builder
126
170
  .build.value
127
171
  ```
128
172
 
129
- ## Instrumentation Only
173
+ ## Custom Instrumenter
174
+
175
+ 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.
176
+
177
+ Here's an example of a simple logging instrumenter:
130
178
 
131
179
  ```ruby
132
- TypedCache.configure_instrumentation do |config|
133
- config.enabled = true
134
- config.namespace = "my_app_cache"
180
+ class LoggingInstrumenter
181
+ include TypedCache::Instrumenter
182
+ include TypedCache::Instrumenters::Mixins::NamespacedSingleton
183
+
184
+ def instrument(operation, key, **payload, &block)
185
+ puts "[CACHE] Starting: #{operation} for key #{key}"
186
+ result = block.call
187
+ puts "[CACHE] Finished: #{operation} for key #{key}"
188
+ result
189
+ end
190
+
191
+ def subscribe(operation, **_filters, &block)
192
+ # For simplicity, this example doesn't implement a full subscription model,
193
+ # but in a real-world scenario, you would store and manage callbacks here.
194
+ puts "[CACHE] Subscribed to '#{operation}'"
195
+ end
135
196
  end
136
197
 
137
- cache = TypedCache.builder
198
+ # Register the new instrumenter
199
+ TypedCache::Instrumenters.register(:logger, LoggingInstrumenter)
200
+
201
+ # Use it in the builder
202
+ logging_cache = TypedCache.builder
138
203
  .with_backend(:memory)
139
- .with_instrumentation
204
+ .with_instrumentation(:logger)
140
205
  .build.value
141
206
 
142
- cache.set(cache.namespace.key("metrics"), 42)
143
- ```
144
-
145
- ## Configuration Snippet
207
+ # Subscribe to an event
208
+ logging_cache.instrumenter.subscribe("get")
146
209
 
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)
210
+ # Operations will now be logged
211
+ logging_cache.ref("test").set("hello")
212
+ # => [CACHE] Starting: set for key test
213
+ # => [CACHE] Finished: set for key test
158
214
  ```
159
215
 
160
216
  ## Thread Safety
@@ -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
@@ -84,7 +87,7 @@ module TypedCache
84
87
  #: (cache_key, V) -> either[Error, Snapshot[V]]
85
88
  def set(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
93
  Either.right(Snapshot.new(value, source: :cache))
@@ -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
 
@@ -1,8 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'typed_cache/namespace'
4
+ require 'typed_cache/cache_key'
5
+ require 'typed_cache/errors'
6
+
7
+ require 'typed_cache/store'
8
+ require 'typed_cache/backends'
9
+ require 'typed_cache/decorators'
10
+ require 'typed_cache/instrumenters'
11
+
12
+ require 'dry/struct'
13
+ require 'dry/types'
14
+
3
15
  module TypedCache
4
16
  class CacheBuilder
5
17
  # @rbs! type config = TypedCache::typed_cache_config
18
+ # @rbs! type instrumenter_source = :default | :dry | :rails | Instrumenter
19
+
20
+ class BackendConfig < Dry::Struct
21
+ attribute :name, Dry.Types::Symbol
22
+ attribute :args, Dry.Types::Array.of(Dry.Types::Any)
23
+ attribute :options, Dry.Types::Hash.map(Dry.Types::Symbol, Dry.Types::Any)
24
+ end
25
+
26
+ class DecoratorConfig < Dry::Struct
27
+ attribute :name, Dry.Types::Symbol
28
+ attribute :options, Dry.Types::Hash.map(Dry.Types::Symbol, Dry.Types::Any)
29
+ end
6
30
 
7
31
  # @rbs (config, Registry[backend[untyped]], Registry[decorator[untyped]]) -> void
8
32
  def initialize(config, backend_registry = Backends, decorator_registry = Decorators)
@@ -10,15 +34,13 @@ module TypedCache
10
34
  @backend_registry = backend_registry
11
35
  @decorator_registry = decorator_registry
12
36
 
13
- @backend_name = nil
14
- @backend_args = []
15
- @backend_options = {}
16
- @decorators = []
37
+ @backend_config = nil
38
+ @decorator_configs = []
17
39
  end
18
40
 
19
41
  # Builds the cache - the only method that can fail
20
42
  # @rbs (?Namespace) -> either[Error, Store[V]]
21
- def build(namespace = Namespace.at(@config.default_namespace))
43
+ def build(namespace = Namespace.at(@config.instrumentation.namespace))
22
44
  validate_and_build(namespace)
23
45
  end
24
46
 
@@ -26,22 +48,22 @@ module TypedCache
26
48
  # Invalid configurations are caught during build()
27
49
  # @rbs (Symbol, *untyped, **untyped) -> self
28
50
  def with_backend(name, *args, **options)
29
- @backend_name = name
30
- @backend_args = args
31
- @backend_options = options
51
+ @backend_config = BackendConfig.new(name:, args:, options:)
32
52
  self
33
53
  end
34
54
 
35
55
  # Adds an arbitrary decorator by registry key
36
56
  # @rbs (Symbol) -> self
37
- def with_decorator(name)
38
- @decorators << name
57
+ def with_decorator(name, **options)
58
+ @decorator_configs << DecoratorConfig.new(name:, options:)
39
59
  self
40
60
  end
41
61
 
42
- # @rbs () -> self
43
- def with_instrumentation
44
- with_decorator(:instrumented)
62
+ # Adds instrumentation using the specified strategy.
63
+ # @rbs (instrumenter_source) -> either[Error, self]
64
+ def with_instrumentation(source = :default)
65
+ @instrumenter_source = source
66
+ self
45
67
  end
46
68
 
47
69
  private
@@ -49,29 +71,60 @@ module TypedCache
49
71
  # @rbs (Namespace) -> either[Error, Store[V]]
50
72
  def validate_and_build(namespace)
51
73
  create_store(namespace).bind do |store|
52
- apply_decorators(store)
74
+ apply_decorators(store).bind do |decorated_store|
75
+ apply_instrumentation(decorated_store)
76
+ end
53
77
  end
54
78
  end
55
79
 
56
80
  # @rbs (Namespace) -> either[Error, Store[V]]
57
81
  def create_store(namespace)
58
- return Either.left(ArgumentError.new('Backend not configured')) unless @backend_name
82
+ return Either.left(ArgumentError.new('Backend not configured')) unless @backend_config
59
83
 
60
84
  # Prepend namespace to the arguments for the backend constructor
61
- @backend_registry.resolve(@backend_name, namespace, *@backend_args, **@backend_options)
85
+ @backend_registry.resolve(@backend_config.name, namespace, *@backend_config.args, **@backend_config.options)
62
86
  end
63
87
 
64
88
  # @rbs (Store[V]) -> either[Error, Store[V]]
65
89
  def apply_decorators(store)
66
- return Either.right(store) if @decorators.empty?
90
+ return Either.right(store) if @decorator_configs.empty?
67
91
 
68
- @decorators.reduce(Either.right(store)) do |result, decorator_name|
92
+ names = @decorator_configs.map(&:name)
93
+
94
+ name_counts = names.tally
95
+ duplicates = name_counts.keys.select { |name| name_counts[name] > 1 }
96
+ return Either.left(ArgumentError.new("Duplicate decorator: #{duplicates.join(", ")}")) if duplicates.any?
97
+
98
+ @decorator_configs.reduce(Either.right(store)) do |result, decorator_config|
69
99
  result.bind do |current_store|
70
- @decorator_registry.resolve(decorator_name, current_store)
100
+ @decorator_registry.resolve(decorator_config.name, current_store, **decorator_config.options)
71
101
  end
72
102
  end
73
103
  rescue => e
74
104
  Either.left(StoreError.new(:decorator_application, 'decorator', "Failed to apply decorator: #{e.message}", e))
75
105
  end
106
+
107
+ def apply_instrumentation(store)
108
+ return Either.right(store) unless @instrumenter_source
109
+
110
+ instrumenter =
111
+ case @instrumenter_source
112
+ when Symbol
113
+ Instrumenters.resolve(@instrumenter_source, namespace: @config.default_namespace)
114
+ when Instrumenter
115
+ Either.right(@instrumenter_source)
116
+ else
117
+ Either.left(TypedCache::TypeError.new(
118
+ ':default | :dry | :rails | Instrumenter',
119
+ @instrumenter_source.class.name,
120
+ @instrumenter_source,
121
+ "Invalid instrumenter source: #{@instrumenter_source.inspect}",
122
+ ))
123
+ end
124
+
125
+ instrumenter.bind do |i|
126
+ @decorator_registry.resolve(:instrumented, store, instrumenter: i)
127
+ end
128
+ end
76
129
  end
77
130
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'namespace'
4
+
3
5
  module TypedCache
4
6
  class CacheKey
5
7
  extend Forwardable
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'cache_key'
4
+ require_relative 'store'
5
+ require_relative 'snapshot'
6
+
3
7
  module TypedCache
4
8
  # A monadic wrapper for cached values that provides safe access with rich error context.
5
9
  # All operations return Either[Error, Snapshot[V]] to provide detailed information about