better_auth-redis-storage 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17fa8afb46d292631de0909478ece0012c2b77dd1e08c6381c522992ca29584c
4
- data.tar.gz: cbfec6b35cb9b1b65c6ecfa2b40873cc38545d2694a2fc731081e6457945bd87
3
+ metadata.gz: bf3169d1223b56ee0f482b4eb81c7d748f1014bbd52c43a98e83bea301155820
4
+ data.tar.gz: 6e00528c060b9cd991dfbb8e204bd736c81b931140d168b1a40b1ed9e4a33ba8
5
5
  SHA512:
6
- metadata.gz: 254e99d01478165d5b559f6e6fe552ef75c5d5a0513691768d630ebc4aedddd981f6ba15b45de927944a491af8c517b77b0dda0ca8b9fde5d271e2f6d0f8af0b
7
- data.tar.gz: fb2a10201de2b4c05ba58770205d0bdb4f702a1468818c49d00c118f770b2cd1a75fe21e7859b0cfe5744dac3e7b93f48f1154aa8c9c24d543196d58af3434a0
6
+ metadata.gz: fe7311656877d8aa018c6644d795bf1dbc0111640458f7de9fe8ab17991d72c26bb81624271e0267f4be8a540482e729e98f77413714c9f5507aca51eef3b403
7
+ data.tar.gz: ccbacf369690383a7798049b09fcc4c6e1b04e3fdb9fed42f97908ad677cd7de6243cdc3875dd7b2573bfb5440ce302489431e57c030cbf3bbd8b191632a366d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.0 - 2026-05-05
6
+
7
+ - Validate `scan_count` as either `nil` or a positive `Integer`.
8
+ - Reject `nil` logical keys before prefixing Redis keys.
9
+ - Coerce positive finite non-Integer `Numeric` TTL values for `SETEX`.
10
+ - Fall back to plain `SET` for positive sub-second numeric TTLs that would truncate to `0`.
11
+ - Delete `clear` matches in chunks to avoid oversized Redis `DEL` commands.
12
+ - Stream `clear` deletion by `SCAN` page when `scan_count:` is configured.
13
+ - Add `atomic_clear:` opt-in generation-scoped keys so `clear` is logically atomic under concurrent writers.
14
+ - Run the real Redis integration suite explicitly in CI and release verification.
15
+ - Document Redis operational caveats for empty prefixes, key ordering, TTLs, and clusters.
16
+
5
17
  ## 0.2.0 - 2026-04-29
6
18
 
7
19
  - Add `BetterAuth.redis_storage` and `BetterAuth::RedisStorage.redisStorage` builders for upstream-shaped Redis storage configuration.
data/README.md CHANGED
@@ -47,19 +47,27 @@ storage = BetterAuth::RedisStorage.redisStorage(client: redis)
47
47
  storage = BetterAuth::RedisStorage.new(
48
48
  client: redis,
49
49
  key_prefix: "better-auth:",
50
- scan_count: nil
50
+ scan_count: nil,
51
+ atomic_clear: false
51
52
  )
52
53
  ```
53
54
 
54
55
  `client` must respond to `get`, `set`, `setex`, `del`, and `keys`. It should
55
- also respond to `scan` when `scan_count:` is configured. This matches the
56
- interfaces exposed by the `redis` and `redis-namespace` gems.
56
+ also respond to `scan` when `scan_count:` is configured, and to `incr` when
57
+ `atomic_clear:` is enabled. This matches the interfaces exposed by the `redis`
58
+ and `redis-namespace` gems.
57
59
 
58
60
  `key_prefix` defaults to `"better-auth:"`. Passing `nil` falls back to the
59
61
  default. Any other value, including the empty string, is honored verbatim.
60
62
  Redis databases are not isolation boundaries for shared clients; applications
61
63
  sharing a Redis instance should use distinct prefixes.
62
64
 
65
+ > **Warning:** Passing `key_prefix: ""` puts Better Auth keys at the root of
66
+ > the selected Redis logical namespace. `list_keys` and `clear` then match `*`,
67
+ > so collisions across apps or tenants are possible and `clear` deletes every
68
+ > key in that Redis database. Use an application-specific prefix unless the
69
+ > Redis database is fully dedicated to Better Auth.
70
+
63
71
  `scan_count` is a Ruby-only opt-in for large Redis databases. By default the gem
64
72
  uses `KEYS "#{key_prefix}*"` to match upstream exactly. Set `scan_count:` to a
65
73
  positive count such as `100`, `500`, or `1000` to use `SCAN` instead:
@@ -68,6 +76,23 @@ positive count such as `100`, `500`, or `1000` to use `SCAN` instead:
68
76
  storage = BetterAuth::RedisStorage.new(client: redis, scan_count: 500)
69
77
  ```
70
78
 
79
+ `atomic_clear` is a Ruby-only opt-in for applications that need `clear` to be
80
+ logically atomic under concurrent writers:
81
+
82
+ ```ruby
83
+ storage = BetterAuth::RedisStorage.new(
84
+ client: redis,
85
+ scan_count: 500,
86
+ atomic_clear: true
87
+ )
88
+ ```
89
+
90
+ When enabled, data keys are stored under a generation prefix such as
91
+ `better-auth:v1:<key>`. Calling `clear` atomically increments the generation key
92
+ so new reads and writes immediately move to the next generation. The previous
93
+ generation is then deleted best-effort, but correctness does not depend on that
94
+ physical cleanup finishing immediately.
95
+
71
96
  ## Behavior
72
97
 
73
98
  The storage object implements the Better Auth secondary storage contract:
@@ -82,13 +107,18 @@ storage.clear
82
107
 
83
108
  `listKeys` is available as a camelCase alias for upstream parity.
84
109
 
110
+ `list_keys` returns every matching logical key but Redis does not guarantee key
111
+ order for `KEYS` or `SCAN`. Sort the returned array in application code when a
112
+ stable order matters.
113
+
85
114
  TTL handling for `set(key, value, ttl)`:
86
115
 
87
116
  | TTL value | Redis command |
88
117
  | --- | --- |
89
- | `nil`, non-numeric strings, `0`, negative numbers | `set(prefixed_key, value)` |
118
+ | `nil`, non-numeric strings, `0`, negative numbers, non-finite numbers | `set(prefixed_key, value)` |
90
119
  | Positive `Integer` | `setex(prefixed_key, ttl, value)` |
91
- | Positive `Float` | `setex(prefixed_key, ttl.to_i, value)` |
120
+ | Positive finite `Float` or other `Numeric` values `>= 1` | `setex(prefixed_key, ttl.to_i, value)` |
121
+ | Positive finite `Float` or other `Numeric` values `< 1` | `set(prefixed_key, value)` |
92
122
  | Positive numeric `String` | `setex(prefixed_key, ttl.to_i, value)` |
93
123
 
94
124
  `set`, `delete`, and `clear` return `nil`, mirroring upstream's `Promise<void>`
@@ -98,6 +128,23 @@ contract in Ruby form. Tests and applications should assert stored values via
98
128
  `clear` intentionally differs from upstream when there are no matching keys:
99
129
  upstream calls `del(...keys)` even when `keys` is empty, while this Ruby gem
100
130
  skips `del` to avoid Redis `ERR wrong number of arguments for 'del'`.
131
+ When keys do exist, `clear` deletes them in batches of
132
+ `BetterAuth::RedisStorage::DELETE_CHUNK_SIZE` keys per `del` call to avoid very
133
+ large Redis argument lists.
134
+
135
+ With `atomic_clear: true`, `clear` increments a generation key with Redis
136
+ `INCR`, making old generation keys immediately invisible to `get`, `set`,
137
+ `delete`, `list_keys`, and Better Auth itself. Cleanup of the old generation is
138
+ best-effort and uses `SCAN` when `scan_count:` is configured.
139
+
140
+ Redis Cluster users should treat `list_keys` and `clear` as operationally
141
+ constrained helpers. This adapter does not scan every cluster node, and
142
+ multi-key `del` calls require keys to live in a compatible hash slot. Prefer a
143
+ single-slot prefix strategy such as Redis hash tags when using these helpers in
144
+ clustered deployments. `atomic_clear: true` improves the logical `clear`
145
+ contract because correctness uses a single `INCR` generation key, but physical
146
+ cleanup of old generations remains subject to the connected client's scan
147
+ coverage.
101
148
 
102
149
  ## Better Auth Usage
103
150
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  class RedisStorage
5
- VERSION = "0.6.2"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -4,28 +4,33 @@ require "better_auth"
4
4
  require_relative "redis_storage/version"
5
5
 
6
6
  module BetterAuth
7
- def self.redis_storage(client:, key_prefix: RedisStorage::DEFAULT_KEY_PREFIX, scan_count: nil)
8
- RedisStorage.new(client: client, key_prefix: key_prefix, scan_count: scan_count)
7
+ def self.redis_storage(client:, key_prefix: RedisStorage::DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
8
+ RedisStorage.new(client: client, key_prefix: key_prefix, scan_count: scan_count, atomic_clear: atomic_clear)
9
9
  end
10
10
 
11
11
  class RedisStorage
12
12
  DEFAULT_KEY_PREFIX = "better-auth:"
13
13
  SCAN_DEFAULT_COUNT = 100
14
+ DELETE_CHUNK_SIZE = 500
14
15
 
15
- attr_reader :client, :key_prefix, :scan_count
16
+ attr_reader :client, :key_prefix, :scan_count, :atomic_clear
16
17
 
17
- def self.build(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil)
18
- new(client: client, key_prefix: key_prefix, scan_count: scan_count)
18
+ def self.build(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
19
+ new(client: client, key_prefix: key_prefix, scan_count: scan_count, atomic_clear: atomic_clear)
19
20
  end
20
21
 
21
- def self.redisStorage(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil)
22
- new(client: client, key_prefix: key_prefix, scan_count: scan_count)
22
+ def self.redisStorage(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
23
+ new(client: client, key_prefix: key_prefix, scan_count: scan_count, atomic_clear: atomic_clear)
23
24
  end
24
25
 
25
- def initialize(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil)
26
+ def initialize(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
26
27
  @client = client
27
28
  @key_prefix = key_prefix.nil? ? DEFAULT_KEY_PREFIX : key_prefix.to_s
29
+ if !scan_count.nil? && !(scan_count.is_a?(Integer) && scan_count.positive?)
30
+ raise ArgumentError, "scan_count must be nil or a positive Integer; got #{scan_count.inspect}"
31
+ end
28
32
  @scan_count = scan_count
33
+ @atomic_clear = !!atomic_clear
29
34
  end
30
35
 
31
36
  def get(key)
@@ -49,14 +54,16 @@ module BetterAuth
49
54
  end
50
55
 
51
56
  def list_keys
52
- storage_keys.map { |key| unprefix_key(key) }
57
+ prefix = storage_prefix
58
+ storage_keys(prefix).map { |key| unprefix_key(key, prefix) }
53
59
  end
54
60
 
55
61
  def clear
56
- keys = storage_keys
57
- # Upstream calls del(...keys) unconditionally; Ruby keeps this guard to
58
- # avoid Redis ERR wrong number of arguments when no prefixed keys exist.
59
- client.del(*keys) unless keys.empty?
62
+ if atomic_clear
63
+ clear_current_generation
64
+ else
65
+ delete_matching_keys(storage_prefix)
66
+ end
60
67
  nil
61
68
  end
62
69
 
@@ -65,28 +72,81 @@ module BetterAuth
65
72
  private
66
73
 
67
74
  def prefix_key(key)
68
- "#{key_prefix}#{key}"
75
+ raise ArgumentError, "secondary storage key must not be nil" if key.nil?
76
+
77
+ "#{storage_prefix}#{key}"
69
78
  end
70
79
 
71
- def unprefix_key(key)
72
- key.sub(/\A#{Regexp.escape(key_prefix)}/, "")
80
+ def unprefix_key(key, prefix = storage_prefix)
81
+ key.sub(/\A#{Regexp.escape(prefix)}/, "")
73
82
  end
74
83
 
75
- def storage_keys
76
- return scan_keys if scan_count
84
+ def storage_prefix(generation = current_generation)
85
+ return key_prefix unless atomic_clear
77
86
 
78
- client.keys("#{key_prefix}*")
87
+ "#{key_prefix}v#{generation}:"
79
88
  end
80
89
 
81
- def scan_keys
82
- cursor = "0"
90
+ def generation_key
91
+ "#{key_prefix}__generation__"
92
+ end
93
+
94
+ def current_generation
95
+ return nil unless atomic_clear
96
+
97
+ generation = client.get(generation_key).to_i
98
+ generation.positive? ? generation : 1
99
+ end
100
+
101
+ def clear_current_generation
102
+ generation = current_generation
103
+ bump_generation(generation)
104
+ delete_matching_keys(storage_prefix(generation), single_key: true)
105
+ end
106
+
107
+ def bump_generation(previous_generation)
108
+ generation = client.incr(generation_key).to_i
109
+ generation = client.incr(generation_key).to_i if generation <= previous_generation.to_i
110
+ generation
111
+ end
112
+
113
+ def storage_keys(prefix = storage_prefix)
114
+ return scan_keys(prefix) if scan_count
115
+
116
+ client.keys("#{prefix}*")
117
+ end
118
+
119
+ def scan_keys(prefix = storage_prefix)
83
120
  matches = []
121
+ each_scan_batch(prefix) { |keys| matches.concat(keys) }
122
+ matches
123
+ end
124
+
125
+ def each_scan_batch(prefix = storage_prefix)
126
+ cursor = "0"
84
127
  loop do
85
- cursor, keys = client.scan(cursor, match: "#{key_prefix}*", count: scan_count)
86
- matches.concat(keys)
128
+ cursor, keys = client.scan(cursor, match: "#{prefix}*", count: scan_count)
129
+ yield keys
87
130
  break if cursor.to_s == "0"
88
131
  end
89
- matches
132
+ end
133
+
134
+ def delete_matching_keys(prefix, single_key: false)
135
+ if scan_count
136
+ each_scan_batch(prefix) { |keys| delete_keys(keys, single_key: single_key) }
137
+ else
138
+ delete_keys(storage_keys(prefix), single_key: single_key)
139
+ end
140
+ end
141
+
142
+ def delete_keys(keys, single_key: false)
143
+ # Upstream calls del(...keys) unconditionally; Ruby keeps this guard to
144
+ # avoid Redis ERR wrong number of arguments when no prefixed keys exist.
145
+ if single_key
146
+ keys.each { |key| client.del(key) }
147
+ else
148
+ keys.each_slice(DELETE_CHUNK_SIZE) { |chunk| client.del(*chunk) }
149
+ end
90
150
  end
91
151
 
92
152
  def coerce_ttl(ttl)
@@ -96,12 +156,19 @@ module BetterAuth
96
156
  when Integer
97
157
  ttl
98
158
  when Float
99
- ttl.to_i
159
+ ttl.finite? ? ttl : nil
100
160
  when String
101
161
  Integer(ttl, exception: false)
162
+ when Numeric
163
+ ttl.to_f
102
164
  end
103
165
 
104
- numeric&.positive? ? numeric : nil
166
+ return nil unless numeric.is_a?(Numeric)
167
+ return nil unless numeric.respond_to?(:positive?) && numeric.positive?
168
+ return nil if numeric.respond_to?(:finite?) && !numeric.finite?
169
+
170
+ seconds = numeric.is_a?(Integer) ? numeric : numeric.to_i
171
+ seconds.positive? ? seconds : nil
105
172
  end
106
173
  end
107
174
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-redis-storage
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala