safe_memoize 1.3.0 → 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e47bd422b1516a4f775e49ad3f58bf916df60c094aae7dfddf652fa78e44d73
4
- data.tar.gz: 2adc318d5cda8858d1ee49a2dd32080acea1e8163807e424366d9e8e42a30943
3
+ metadata.gz: e34f9a8aaa025eca2f817b633409686788e99442f10de2fede62f30443f9a0fc
4
+ data.tar.gz: 21560a08d7060ee77da109da4a91024899674da14ec41e051ff5cb08a5d913f2
5
5
  SHA512:
6
- metadata.gz: de347fc9b0e6f9ad1385f0f5f3a1cc03595f9cf8b969f354cd87bd5173e318ec9af98cf31d2253617c369a8cd7ea9d6c08fbc489d22d30775b912f6867bab70f
7
- data.tar.gz: '08153a2284f53e6110d4f3fae0a84ae72d2739bc0146e70cbabd5a6cce30b544122204623185ad96c1706c1de518feb583a227fd8b74018b896775f884ac7053'
6
+ metadata.gz: '0408a2a2322c7861ec0eab88a62e02a15c59c552301e8fe3b7712691d03f385c81a2efbcf8ef06da57b50ae304edc18a7794001c43093b9fc49c180449a68199'
7
+ data.tar.gz: 421ad6f07416395bef2723fd00a20d7e20c4becd2fec0da9f774ca476d370b1bb459b8d895c2cb7535da9898e27afcfdfcf3d089923ff8aac426dee0c89c5aa8
@@ -0,0 +1,15 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: [eclectic-coding] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
data/CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.4.0] - 2026-06-02
12
+
13
+ ### Added
14
+
15
+ - `safe_memoize_options(**opts)` class-level macro — sets default options for every subsequent `memoize` call on the class. Per-call options take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults. Accepts all `memoize` options except mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call. Call with no arguments to clear class-level defaults.
16
+ - `copy_on_read: true` option on `memoize` — returns a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the cached value on every read, preventing callers from mutating shared cached state. `nil` and frozen values are returned as-is. Works across all cache paths (per-instance, LRU, shared, fiber-local, and external store). Incompatible with `ractor_safe:` (ractor-safe values are always frozen; use that guarantee instead). Can be set as a class default via `safe_memoize_options copy_on_read: true`.
17
+
11
18
  ## [1.3.0] - 2026-05-28
12
19
 
13
20
  ### Added
data/README.md CHANGED
@@ -77,6 +77,8 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
77
77
  - [Named shared caches via `shared_cache: "name"` — cross-class cache sharing backed by a globally-registered store](#named-shared-caches)
78
78
  - [Automatic cache busting via `cache_bust:` — version-token-based invalidation; works with ActiveRecord `updated_at` and any comparable value](#automatic-cache-busting)
79
79
  - [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)
80
+ - [Per-class default options via `safe_memoize_options` — set TTL, max size, copy-on-read, and other defaults for every `memoize` call on the class without repeating them](#per-class-default-options-safe_memoize_options)
81
+ - [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read)
80
82
 
81
83
  ## Installation
82
84
 
@@ -1372,6 +1374,83 @@ end
1372
1374
 
1373
1375
  [↑ Back to features](#features)
1374
1376
 
1377
+ ## Per-class default options (`safe_memoize_options`)
1378
+
1379
+ `safe_memoize_options` sets option defaults for every `memoize` call on the class, eliminating repetition when many methods share the same TTL, LRU cap, or other option. Per-call options still take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults.
1380
+
1381
+ ```ruby
1382
+ class ApiClient
1383
+ prepend SafeMemoize
1384
+ safe_memoize_options ttl: 60, max_size: 200, copy_on_read: true
1385
+
1386
+ def fetch(id) = http.get(id)
1387
+ memoize :fetch # uses ttl: 60, max_size: 200, copy_on_read: true
1388
+
1389
+ def list = http.get("/all")
1390
+ memoize :list, ttl: 300 # uses max_size: 200, copy_on_read: true; ttl: 300 overrides
1391
+ end
1392
+ ```
1393
+
1394
+ Accepted options are the same as `memoize` minus the mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call because they change the entire execution path:
1395
+
1396
+ ```ruby
1397
+ safe_memoize_options(
1398
+ ttl: 60,
1399
+ max_size: 100,
1400
+ ttl_refresh: true,
1401
+ copy_on_read: true,
1402
+ namespace: "v2",
1403
+ if: ->(v) { v.present? },
1404
+ cache_bust: :updated_at
1405
+ )
1406
+ ```
1407
+
1408
+ Call with no arguments to clear all class-level defaults:
1409
+
1410
+ ```ruby
1411
+ MyClass.safe_memoize_options # clears — subsequent memoize calls use global config or per-call options only
1412
+ ```
1413
+
1414
+ [↑ Back to features](#features)
1415
+
1416
+ ## Copy-on-read
1417
+
1418
+ Pass `copy_on_read: true` to `memoize` to return a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the stored value on every cache read. This prevents callers from mutating the shared cached object:
1419
+
1420
+ ```ruby
1421
+ class ConfigService
1422
+ prepend SafeMemoize
1423
+
1424
+ def settings = {host: "localhost", port: 8080}
1425
+ memoize :settings, copy_on_read: true
1426
+ end
1427
+
1428
+ svc = ConfigService.new
1429
+ result = svc.settings
1430
+ result[:host] = "mutated" # only affects the caller's copy
1431
+
1432
+ svc.settings[:host] # => "localhost" — cache is unaffected
1433
+ ```
1434
+
1435
+ `nil` and frozen values are returned as-is (no dup attempted). `copy_on_read:` works across all cache paths: per-instance hash, LRU (`max_size:`), class-level shared (`shared: true`), fiber-local (`fiber_local: true`), and external stores. It is incompatible with `ractor_safe: true` (ractor-safe values are always frozen; rely on that guarantee instead).
1436
+
1437
+ Set it as a class-wide default with `safe_memoize_options`:
1438
+
1439
+ ```ruby
1440
+ class ReportService
1441
+ prepend SafeMemoize
1442
+ safe_memoize_options copy_on_read: true
1443
+
1444
+ def summary = build_summary
1445
+ memoize :summary
1446
+
1447
+ def details = build_details
1448
+ memoize :details
1449
+ end
1450
+ ```
1451
+
1452
+ [↑ Back to features](#features)
1453
+
1375
1454
  ## Ractor-safe shared cache
1376
1455
 
1377
1456
  Pass `ractor_safe: true` (together with `shared: true`) to replace the `Mutex`-backed class-level shared cache with a supervisor `Ractor` that owns the mutable cache hash. All reads and writes are serialised through message passing, so the cache is safe to use from multiple Ractors.
@@ -1531,6 +1610,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1531
1610
  | `namespace:` | `String \| nil` | `nil` | Namespace prefix prepended to the cache key's first element; must not contain `:`; takes precedence over the class-level and global namespace |
1532
1611
  | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` |
1533
1612
  | `cache_bust:` | `Proc \| Symbol \| nil` | `nil` | Version-token callable; invoked on the instance at each lookup; token is folded into the key; incompatible with `key:` |
1613
+ | `copy_on_read:` | `Boolean` | `false` | Return a `dup`/`deep_dup` of the cached value on every read; protects shared state from caller mutation; nil and frozen values pass through; incompatible with `ractor_safe:` |
1534
1614
  | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1535
1615
 
1536
1616
  ### `memoize_all` options (class method)
@@ -1544,6 +1624,12 @@ All `memoize` option keys above, plus:
1544
1624
  | `include_protected:` | `Boolean` | `false` |
1545
1625
  | `include_private:` | `Boolean` | `false` |
1546
1626
 
1627
+ ### `safe_memoize_options` (class method)
1628
+
1629
+ | Option key | Type | Default | Notes |
1630
+ |---|---|---|---|
1631
+ | any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
1632
+
1547
1633
  ### Instance methods (public)
1548
1634
 
1549
1635
  **Inspection**
data/ROADMAP.md CHANGED
@@ -4,6 +4,37 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
+ ## v1.5.0 — Cache Invalidation
8
+
9
+ *Goal: group-level cache invalidation so related methods can be busted in one operation.*
10
+
11
+ | Feature | Description | Status |
12
+ |---|---|---|
13
+ | Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Planned |
14
+
15
+ ---
16
+
17
+ ## v1.6.0 — Resilience
18
+
19
+ *Goal: make external-store memoization resilient to infrastructure failures.*
20
+
21
+ | Feature | Description | Status |
22
+ |---|---|---|
23
+ | Circuit breaker for external stores | When a `store:` adapter raises on `read` or `write`, automatically fall back to the per-instance in-process hash rather than propagating the exception; configurable error threshold and recovery probe interval | Planned |
24
+
25
+ ---
26
+
27
+ ## v1.7.0 — Advanced Store Features
28
+
29
+ *Goal: multi-process performance patterns for high-traffic deployments.*
30
+
31
+ | Feature | Description | Status |
32
+ |---|---|---|
33
+ | Multi-level (L1/L2) caching | `store: [memory_store, redis_store]` — check in-process first, fall back to the remote store on miss, and promote to L1 on read; each level can have independent TTL and eviction settings | Planned |
34
+ | Stampede protection | Probabilistic early expiry (XFetch algorithm) for external stores; recomputes slightly before a TTL expires to prevent multiple processes hitting a cold miss simultaneously | Planned |
35
+
36
+ ---
37
+
7
38
  ## v2.0.0 — Next Generation (Long Horizon)
8
39
 
9
40
  *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
@@ -37,7 +37,7 @@ module SafeMemoize
37
37
  # a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible
38
38
  # from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
39
39
  # +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
40
- # +ttl_refresh:+, +key:+, and +store:+.
40
+ # +ttl_refresh:+, +key:+, +store:+, and +copy_on_read:+.
41
41
  # @param namespace [String, nil] prefix prepended to every cache key for this method,
42
42
  # scoping it to a logical partition. Takes precedence over both the class-level
43
43
  # {#safe_memoize_namespace} and the global {SafeMemoize::Configuration#namespace}.
@@ -59,6 +59,11 @@ module SafeMemoize
59
59
  # {SafeMemoize.register_shared_cache} before the class is loaded to supply a custom
60
60
  # adapter. Incompatible with +shared:+, +store:+, +fiber_local:+, +ractor_safe:+,
61
61
  # and +max_size:+. Composes naturally with +namespace:+, +ttl:+, +if:+, and +key:+.
62
+ # @param copy_on_read [Boolean] when +true+, every cache read returns a +dup+ (or
63
+ # +deep_dup+ when available) of the stored value rather than the cached object
64
+ # itself. Prevents callers from mutating shared cached state. Frozen and +nil+
65
+ # values are returned as-is. Incompatible with +ractor_safe:+ (ractor values are
66
+ # always frozen; use that guarantee instead).
62
67
  # @return [void]
63
68
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
64
69
  #
@@ -73,10 +78,13 @@ module SafeMemoize
73
78
  # @example Conditional — only cache successful responses
74
79
  # memoize :fetch, if: ->(v) { v[:status] == 200 }
75
80
  #
81
+ # @example Copy-on-read — protect mutable cached config
82
+ # memoize :config, copy_on_read: true
83
+ #
76
84
  # @example With a custom store
77
85
  # STORE = SafeMemoize::Stores::Memory.new
78
86
  # memoize :fetch, store: STORE, ttl: 300
79
- def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false, namespace: nil, shared_cache: nil, cache_bust: nil, **extension_options)
87
+ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, **extension_options)
80
88
  method_name = method_name.to_sym
81
89
 
82
90
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -102,18 +110,46 @@ module SafeMemoize
102
110
  cache_bust = injected[:cache_bust] if injected.key?(:cache_bust)
103
111
  end
104
112
 
113
+ # :if and :unless are reserved Ruby keywords; use binding to extract them
114
+ cond_if = binding.local_variable_get(:if)
115
+ cond_unless = binding.local_variable_get(:unless)
116
+
117
+ # Apply class-level defaults (safe_memoize_options) for any still-unset options
118
+ if (cls_defaults = __safe_memoize_defaults__)
119
+ ttl = cls_defaults[:ttl] if ttl.equal?(UNSET) && cls_defaults.key?(:ttl)
120
+ max_size = cls_defaults[:max_size] if max_size.equal?(UNSET) && cls_defaults.key?(:max_size)
121
+ ttl_refresh = cls_defaults[:ttl_refresh] if ttl_refresh.equal?(UNSET) && cls_defaults.key?(:ttl_refresh)
122
+ cond_if = cls_defaults[:if] if cond_if.equal?(UNSET) && cls_defaults.key?(:if)
123
+ cond_unless = cls_defaults[:unless] if cond_unless.equal?(UNSET) && cls_defaults.key?(:unless)
124
+ key = cls_defaults[:key] if key.equal?(UNSET) && cls_defaults.key?(:key)
125
+ cache_bust = cls_defaults[:cache_bust] if cache_bust.equal?(UNSET) && cls_defaults.key?(:cache_bust)
126
+ copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read)
127
+ namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
128
+ store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
129
+ end
130
+
131
+ # Normalize remaining UNSET to original per-call defaults
132
+ ttl = nil if ttl.equal?(UNSET)
133
+ max_size = nil if max_size.equal?(UNSET)
134
+ ttl_refresh = false if ttl_refresh.equal?(UNSET)
135
+ shared = false if shared.equal?(UNSET)
136
+ key = nil if key.equal?(UNSET)
137
+ store = nil if store.equal?(UNSET)
138
+ fiber_local = false if fiber_local.equal?(UNSET)
139
+ ractor_safe = false if ractor_safe.equal?(UNSET)
140
+ namespace = nil if namespace.equal?(UNSET)
141
+ shared_cache = nil if shared_cache.equal?(UNSET)
142
+ cache_bust = nil if cache_bust.equal?(UNSET)
143
+ copy_on_read = false if copy_on_read.equal?(UNSET)
144
+ cond_if = nil if cond_if.equal?(UNSET)
145
+ cond_unless = nil if cond_unless.equal?(UNSET)
146
+
105
147
  visibility = memoized_method_visibility(method_name)
106
148
 
107
149
  config = SafeMemoize.configuration
108
150
  ttl = config.default_ttl if ttl.nil?
109
151
  max_size = config.default_max_size if max_size.nil?
110
152
 
111
- # :if and :unless are reserved Ruby keywords, so they can't be referenced
112
- # as local variables directly. binding.local_variable_get is the only way
113
- # to read keyword arguments with those names inside the method body.
114
- cond_if = binding.local_variable_get(:if)
115
- cond_unless = binding.local_variable_get(:unless)
116
-
117
153
  ttl = if ttl.nil?
118
154
  nil
119
155
  else
@@ -167,6 +203,7 @@ module SafeMemoize
167
203
  raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
168
204
  raise ArgumentError, "ractor_safe: is incompatible with key:" if key
169
205
  raise ArgumentError, "ractor_safe: is incompatible with store:" if store
206
+ raise ArgumentError, "ractor_safe: is incompatible with copy_on_read:" if copy_on_read
170
207
  end
171
208
 
172
209
  if namespace
@@ -217,6 +254,18 @@ module SafeMemoize
217
254
  ->(result) { !cond_unless.call(result) }
218
255
  end
219
256
 
257
+ # Build a value-duplication function for copy_on_read: true.
258
+ # Frozen and nil values are returned as-is; deep_dup is preferred when available
259
+ # (e.g. ActiveRecord objects) so nested mutable structures are also protected.
260
+ dup_fn = if copy_on_read
261
+ lambda do |v|
262
+ return v if v.nil? || v.frozen?
263
+ v.respond_to?(:deep_dup) ? v.deep_dup : v.dup
264
+ end
265
+ else
266
+ ->(v) { v }
267
+ end
268
+
220
269
  if effective_store
221
270
  miss = SafeMemoize::Stores::Base::MISS
222
271
 
@@ -231,7 +280,7 @@ module SafeMemoize
231
280
  effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
232
281
  record_cache_hit(cache_key)
233
282
  call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
234
- return cached
283
+ return dup_fn.call(cached)
235
284
  end
236
285
 
237
286
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -249,7 +298,7 @@ module SafeMemoize
249
298
  record_cache_miss(cache_key, elapsed_time)
250
299
  call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
251
300
 
252
- value
301
+ dup_fn.call(value)
253
302
  end
254
303
 
255
304
  send(visibility, method_name)
@@ -278,7 +327,7 @@ module SafeMemoize
278
327
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
279
328
  record_cache_hit(cache_key)
280
329
  call_memo_hooks(:on_hit, cache_key, record)
281
- memo_record_value(record)
330
+ dup_fn.call(memo_record_value(record))
282
331
  else
283
332
  call_memo_hooks(:on_expire, cache_key, record) if record
284
333
 
@@ -312,7 +361,7 @@ module SafeMemoize
312
361
  record_cache_miss(cache_key, elapsed_time)
313
362
  call_memo_hooks(:on_miss, cache_key, new_record)
314
363
 
315
- value
364
+ dup_fn.call(value)
316
365
  end
317
366
  end
318
367
 
@@ -356,7 +405,7 @@ module SafeMemoize
356
405
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
357
406
  record_cache_hit(cache_key)
358
407
  call_memo_hooks(:on_hit, cache_key, record)
359
- record[:value]
408
+ dup_fn.call(record[:value])
360
409
  else
361
410
  call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
362
411
 
@@ -388,7 +437,7 @@ module SafeMemoize
388
437
  record_cache_miss(cache_key, elapsed_time)
389
438
  call_memo_hooks(:on_miss, cache_key, new_record)
390
439
 
391
- value
440
+ dup_fn.call(value)
392
441
  end
393
442
  end
394
443
  end
@@ -417,7 +466,7 @@ module SafeMemoize
417
466
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
418
467
  record_cache_hit(cache_key)
419
468
  call_memo_hooks(:on_hit, cache_key, record)
420
- memo_record_value(record)
469
+ dup_fn.call(memo_record_value(record))
421
470
  else
422
471
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
423
472
  value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
@@ -434,7 +483,7 @@ module SafeMemoize
434
483
  record_cache_miss(cache_key, elapsed_time)
435
484
  call_memo_hooks(:on_miss, cache_key, new_record)
436
485
 
437
- value
486
+ dup_fn.call(value)
438
487
  end
439
488
  end
440
489
  else
@@ -442,7 +491,7 @@ module SafeMemoize
442
491
  if (record = memo_cache_record(cache_key))
443
492
  record_cache_hit(cache_key)
444
493
  call_memo_hooks(:on_hit, cache_key, record)
445
- return memo_record_value(record)
494
+ return dup_fn.call(memo_record_value(record))
446
495
  end
447
496
 
448
497
  # Cache miss - compute and store
@@ -459,7 +508,7 @@ module SafeMemoize
459
508
  call_memo_hooks(:on_miss, cache_key, new_record)
460
509
  end
461
510
 
462
- result
511
+ dup_fn.call(result)
463
512
  end
464
513
  end
465
514
 
@@ -520,6 +569,41 @@ module SafeMemoize
520
569
  @__safe_memoize_namespace__ = ns
521
570
  end
522
571
 
572
+ # Sets class-wide default options applied to every subsequent {#memoize} call
573
+ # on this class. Per-call options take precedence; class defaults take
574
+ # precedence over global {SafeMemoize::Configuration} defaults.
575
+ #
576
+ # Call with no arguments (or an empty hash) to clear all class-level defaults.
577
+ #
578
+ # @example Apply a TTL and LRU cap to every memoized method on the class
579
+ # class ApiClient
580
+ # prepend SafeMemoize
581
+ # safe_memoize_options ttl: 60, max_size: 200
582
+ #
583
+ # def fetch(id) = http.get(id)
584
+ # memoize :fetch # uses ttl: 60, max_size: 200
585
+ #
586
+ # def list = http.get("/all")
587
+ # memoize :list, ttl: 300 # uses max_size: 200, ttl: 300
588
+ # end
589
+ #
590
+ # @example Protect all cached values from mutation
591
+ # safe_memoize_options copy_on_read: true
592
+ #
593
+ # @param opts [Hash] any subset of {#memoize} options except mode-switch options
594
+ # (+shared:+, +fiber_local:+, +ractor_safe:+, +shared_cache:+)
595
+ # @return [Hash] the stored defaults
596
+ # @raise [ArgumentError] for disallowed options
597
+ def safe_memoize_options(**opts)
598
+ disallowed = %i[shared fiber_local ractor_safe shared_cache]
599
+ bad = opts.keys & disallowed
600
+ unless bad.empty?
601
+ raise ArgumentError,
602
+ "safe_memoize_options does not accept #{bad.map { |k| ":#{k}" }.join(", ")} — pass mode-switch options per memoize call"
603
+ end
604
+ @__safe_memoize_defaults__ = opts.empty? ? nil : opts
605
+ end
606
+
523
607
  # Memoizes every eligible public instance method defined directly on the class.
524
608
  #
525
609
  # Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
@@ -705,6 +789,10 @@ module SafeMemoize
705
789
  @__safe_memo_class_cache_bust_generators__ ||= {}
706
790
  end
707
791
 
792
+ def __safe_memoize_defaults__
793
+ @__safe_memoize_defaults__
794
+ end
795
+
708
796
  # Resolves the effective first-element key sym for a given bare method name,
709
797
  # applying the active namespace. Used by class-level cache operations where
710
798
  # instance methods (compute_cache_key) are unavailable.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.3.0"
5
+ VERSION = "1.4.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -55,6 +55,9 @@ module SafeMemoize
55
55
  # Rescue this to catch any error raised by the library itself.
56
56
  class Error < StandardError; end
57
57
 
58
+ # @api private — sentinel distinguishing "not passed" from explicit nil/false in memoize kwargs
59
+ UNSET = Object.new.freeze
60
+
58
61
  # @api private
59
62
  SHARED_CACHE_REGISTRY = {}
60
63
  # @api private
data/sig/safe_memoize.rbs CHANGED
@@ -18,6 +18,7 @@ module SafeMemoize
18
18
  @__safe_memo_shared_mutex__: Mutex?
19
19
  @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
20
20
 
21
+ UNSET: untyped
21
22
  SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
22
23
  SHARED_CACHE_MUTEX: Mutex
23
24
  EXTENSION_REGISTRY: Hash[Symbol, untyped]
@@ -64,12 +65,13 @@ module SafeMemoize
64
65
  end
65
66
 
66
67
  module ClassMethods
67
- def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, **untyped extension_options) -> void
68
+ def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, **untyped extension_options) -> void
68
69
  def safe_memoize_store: () -> Stores::Base?
69
70
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
70
71
  def safe_memoize_namespace: () -> String?
71
72
  def safe_memoize_namespace=: (String?) -> String?
72
- def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?) -> void
73
+ def safe_memoize_options: (**untyped opts) -> Hash[Symbol, untyped]?
74
+ def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?, ?copy_on_read: bool) -> void
73
75
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
74
76
  def reset_all_shared_memos: () -> void
75
77
  def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
@@ -85,6 +87,7 @@ module SafeMemoize
85
87
  def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc]
86
88
  def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
87
89
  def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
90
+ def __safe_memoize_defaults__: () -> Hash[Symbol, untyped]?
88
91
  def memoized_method_visibility: (Symbol method_name) -> Symbol
89
92
  end
90
93
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_memoize
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -39,6 +39,7 @@ executables: []
39
39
  extensions: []
40
40
  extra_rdoc_files: []
41
41
  files:
42
+ - ".github/FUNDING.yml"
42
43
  - ".github/workflows/ci.yml"
43
44
  - ".github/workflows/release.yml"
44
45
  - ".yardopts"