familia 2.0.0.pre6 → 2.0.0.pre7
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 +4 -4
- data/.github/workflows/claude-code-review.yml +57 -0
- data/.github/workflows/claude.yml +71 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +32 -13
- data/Gemfile +2 -2
- data/Gemfile.lock +2 -2
- data/docs/wiki/Feature-System-Guide.md +36 -5
- data/docs/wiki/Home.md +30 -20
- data/docs/wiki/Relationships-Guide.md +684 -0
- data/examples/bit_encoding_integration.rb +237 -0
- data/examples/redis_command_validation_example.rb +231 -0
- data/examples/relationships_basic.rb +273 -0
- data/lib/familia/connection.rb +3 -3
- data/lib/familia/data_type.rb +7 -4
- data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
- data/lib/familia/features/encrypted_fields.rb +413 -4
- data/lib/familia/features/expiration.rb +319 -33
- data/lib/familia/features/quantization.rb +385 -44
- data/lib/familia/features/relationships/cascading.rb +438 -0
- data/lib/familia/features/relationships/indexing.rb +370 -0
- data/lib/familia/features/relationships/membership.rb +503 -0
- data/lib/familia/features/relationships/permission_management.rb +264 -0
- data/lib/familia/features/relationships/querying.rb +620 -0
- data/lib/familia/features/relationships/redis_operations.rb +274 -0
- data/lib/familia/features/relationships/score_encoding.rb +442 -0
- data/lib/familia/features/relationships/tracking.rb +379 -0
- data/lib/familia/features/relationships.rb +466 -0
- data/lib/familia/features/transient_fields.rb +192 -10
- data/lib/familia/features.rb +2 -1
- data/lib/familia/horreum/subclass/definition.rb +1 -1
- data/lib/familia/validation/command_recorder.rb +336 -0
- data/lib/familia/validation/expectations.rb +519 -0
- data/lib/familia/validation/test_helpers.rb +443 -0
- data/lib/familia/validation/validator.rb +412 -0
- data/lib/familia/validation.rb +140 -0
- data/lib/familia/version.rb +1 -1
- data/try/edge_cases/hash_symbolization_try.rb +1 -0
- data/try/edge_cases/reserved_keywords_try.rb +1 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/encryption/encryption_core_try.rb +3 -1
- data/try/features/categorical_permissions_try.rb +515 -0
- data/try/features/encryption_fields/concealed_string_core_try.rb +3 -0
- data/try/features/encryption_fields/context_isolation_try.rb +1 -0
- data/try/features/relationships_edge_cases_try.rb +145 -0
- data/try/features/relationships_performance_minimal_try.rb +132 -0
- data/try/features/relationships_performance_simple_try.rb +155 -0
- data/try/features/relationships_performance_try.rb +420 -0
- data/try/features/relationships_performance_working_try.rb +144 -0
- data/try/features/relationships_try.rb +237 -0
- data/try/features/safe_dump_try.rb +3 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/helpers/test_helpers.rb +1 -1
- data/try/horreum/base_try.rb +14 -8
- data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
- data/try/horreum/relations_try.rb +1 -1
- data/try/validation/atomic_operations_try.rb.disabled +320 -0
- data/try/validation/command_validation_try.rb.disabled +207 -0
- data/try/validation/performance_validation_try.rb.disabled +324 -0
- data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
- metadata +32 -4
- data/docs/wiki/RelatableObjects-Guide.md +0 -563
- data/lib/familia/features/relatable_objects.rb +0 -125
- data/try/features/relatable_objects_try.rb +0 -220
@@ -2,48 +2,222 @@
|
|
2
2
|
|
3
3
|
module Familia
|
4
4
|
module Features
|
5
|
-
#
|
5
|
+
# Expiration is a feature that provides Time To Live (TTL) management for Familia
|
6
|
+
# objects and their associated Redis/Valkey data structures. It enables automatic
|
7
|
+
# data cleanup and supports cascading expiration across related objects.
|
8
|
+
#
|
9
|
+
# This feature allows you to:
|
10
|
+
# - Set default expiration times at the class level
|
11
|
+
# - Update expiration times for individual objects
|
12
|
+
# - Cascade expiration settings to related data structures
|
13
|
+
# - Query remaining TTL for objects
|
14
|
+
# - Handle expiration inheritance in class hierarchies
|
15
|
+
#
|
16
|
+
# Example:
|
17
|
+
#
|
18
|
+
# class Session < Familia::Horreum
|
19
|
+
# feature :expiration
|
20
|
+
# default_expiration 1.hour
|
21
|
+
#
|
22
|
+
# field :user_id, :data, :created_at
|
23
|
+
# list :activity_log
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# session = Session.new(user_id: 123, data: { role: 'admin' })
|
27
|
+
# session.save
|
28
|
+
#
|
29
|
+
# # Automatically expires in 1 hour (default_expiration)
|
30
|
+
# session.ttl # => 3599 (seconds remaining)
|
31
|
+
#
|
32
|
+
# # Update expiration to 30 minutes
|
33
|
+
# session.update_expiration(30.minutes)
|
34
|
+
# session.ttl # => 1799
|
35
|
+
#
|
36
|
+
# # Set custom expiration for new objects
|
37
|
+
# session.update_expiration(default_expiration: 2.hours)
|
38
|
+
#
|
39
|
+
# Class-Level Configuration:
|
40
|
+
#
|
41
|
+
# Default expiration can be set at the class level and will be inherited
|
42
|
+
# by subclasses unless overridden:
|
43
|
+
#
|
44
|
+
# class BaseModel < Familia::Horreum
|
45
|
+
# feature :expiration
|
46
|
+
# default_expiration 1.day
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# class ShortLivedModel < BaseModel
|
50
|
+
# default_expiration 5.minutes # Overrides parent
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# class InheritedModel < BaseModel
|
54
|
+
# # Inherits 1.day from BaseModel
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# Cascading Expiration:
|
58
|
+
#
|
59
|
+
# When an object has related data structures (lists, sets, etc.), the
|
60
|
+
# expiration feature automatically applies TTL to all related structures:
|
61
|
+
#
|
62
|
+
# class User < Familia::Horreum
|
63
|
+
# feature :expiration
|
64
|
+
# default_expiration 30.days
|
65
|
+
#
|
66
|
+
# field :email, :name
|
67
|
+
# list :sessions # Will also expire in 30 days
|
68
|
+
# set :permissions # Will also expire in 30 days
|
69
|
+
# hashkey :preferences # Will also expire in 30 days
|
70
|
+
# end
|
71
|
+
#
|
72
|
+
# Fine-Grained Control:
|
73
|
+
#
|
74
|
+
# Related structures can have their own expiration settings:
|
75
|
+
#
|
76
|
+
# class Analytics < Familia::Horreum
|
77
|
+
# feature :expiration
|
78
|
+
# default_expiration 1.year
|
79
|
+
#
|
80
|
+
# field :metric_name
|
81
|
+
# list :hourly_data, default_expiration: 1.week # Shorter TTL
|
82
|
+
# list :daily_data, default_expiration: 1.month # Medium TTL
|
83
|
+
# list :monthly_data # Uses class default (1.year)
|
84
|
+
# end
|
85
|
+
#
|
86
|
+
# Zero Expiration:
|
87
|
+
#
|
88
|
+
# Setting expiration to 0 (zero) disables TTL, making data persist indefinitely:
|
89
|
+
#
|
90
|
+
# session.update_expiration(default_expiration: 0) # No expiration
|
91
|
+
#
|
92
|
+
# TTL Querying:
|
93
|
+
#
|
94
|
+
# Check remaining time before expiration:
|
95
|
+
#
|
96
|
+
# session.ttl # => 3599 (seconds remaining)
|
97
|
+
# session.ttl.zero? # => false (still has time)
|
98
|
+
# expired_session.ttl # => -1 (already expired or no TTL set)
|
99
|
+
#
|
100
|
+
# Integration Patterns:
|
101
|
+
#
|
102
|
+
# # Conditional expiration based on user type
|
103
|
+
# class UserSession < Familia::Horreum
|
104
|
+
# feature :expiration
|
105
|
+
#
|
106
|
+
# field :user_id, :user_type
|
107
|
+
#
|
108
|
+
# def save
|
109
|
+
# super
|
110
|
+
# case user_type
|
111
|
+
# when 'premium'
|
112
|
+
# update_expiration(7.days)
|
113
|
+
# when 'free'
|
114
|
+
# update_expiration(1.hour)
|
115
|
+
# else
|
116
|
+
# update_expiration(default_expiration)
|
117
|
+
# end
|
118
|
+
# end
|
119
|
+
# end
|
120
|
+
#
|
121
|
+
# # Background job cleanup
|
122
|
+
# class DataCleanupJob
|
123
|
+
# def perform
|
124
|
+
# # Extend expiration for active users
|
125
|
+
# active_sessions = Session.where(active: true)
|
126
|
+
# active_sessions.each do |session|
|
127
|
+
# session.update_expiration(session.default_expiration)
|
128
|
+
# end
|
129
|
+
# end
|
130
|
+
# end
|
131
|
+
#
|
132
|
+
# Error Handling:
|
133
|
+
#
|
134
|
+
# The feature validates expiration values and raises descriptive errors:
|
135
|
+
#
|
136
|
+
# session.update_expiration(default_expiration: "invalid")
|
137
|
+
# # => Familia::Problem: Default expiration must be a number
|
138
|
+
#
|
139
|
+
# session.update_expiration(default_expiration: -1)
|
140
|
+
# # => Familia::Problem: Default expiration must be non-negative
|
141
|
+
#
|
142
|
+
# Performance Considerations:
|
143
|
+
#
|
144
|
+
# - TTL operations are performed on Redis/Valkey side with minimal overhead
|
145
|
+
# - Cascading expiration uses pipelining for efficiency when possible
|
146
|
+
# - Zero expiration values skip Redis EXPIRE calls entirely
|
147
|
+
# - TTL queries are direct Redis operations (very fast)
|
6
148
|
#
|
7
149
|
module Expiration
|
8
150
|
@default_expiration = nil
|
9
151
|
|
10
152
|
def self.included(base)
|
11
|
-
Familia.trace :LOADED
|
153
|
+
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
12
154
|
base.extend ClassMethods
|
13
155
|
|
14
|
-
#
|
15
|
-
#
|
156
|
+
# Initialize default_expiration instance variable if not already defined
|
157
|
+
# This ensures the class has a place to store its default expiration setting
|
16
158
|
return if base.instance_variable_defined?(:@default_expiration)
|
17
159
|
|
18
|
-
base.instance_variable_set(:@default_expiration, @default_expiration)
|
160
|
+
base.instance_variable_set(:@default_expiration, @default_expiration)
|
19
161
|
end
|
20
162
|
|
21
|
-
# ClassMethods
|
22
|
-
#
|
23
163
|
module ClassMethods
|
164
|
+
# Set the default expiration time for instances of this class
|
165
|
+
#
|
166
|
+
# @param expiration [Numeric] Time in seconds (can be fractional)
|
167
|
+
#
|
24
168
|
attr_writer :default_expiration
|
25
169
|
|
170
|
+
# Get or set the default expiration time for this class
|
171
|
+
#
|
172
|
+
# When called with an argument, sets the default expiration.
|
173
|
+
# When called without arguments, returns the current default expiration,
|
174
|
+
# checking parent classes and falling back to Familia.default_expiration.
|
175
|
+
#
|
176
|
+
# @param num [Numeric, nil] Expiration time in seconds
|
177
|
+
# @return [Float] The default expiration in seconds
|
178
|
+
#
|
179
|
+
# @example Set default expiration
|
180
|
+
# class MyModel < Familia::Horreum
|
181
|
+
# feature :expiration
|
182
|
+
# default_expiration 1.hour
|
183
|
+
# end
|
184
|
+
#
|
185
|
+
# @example Get default expiration
|
186
|
+
# MyModel.default_expiration # => 3600.0
|
187
|
+
#
|
26
188
|
def default_expiration(num = nil)
|
27
189
|
@default_expiration = num.to_f unless num.nil?
|
28
190
|
@default_expiration || parent&.default_expiration || Familia.default_expiration
|
29
191
|
end
|
30
192
|
end
|
31
193
|
|
194
|
+
# Set the default expiration time for this instance
|
195
|
+
#
|
196
|
+
# @param num [Numeric] Expiration time in seconds
|
197
|
+
#
|
32
198
|
def default_expiration=(num)
|
33
199
|
@default_expiration = num.to_f
|
34
200
|
end
|
35
201
|
|
202
|
+
# Get the default expiration time for this instance
|
203
|
+
#
|
204
|
+
# Returns the instance-specific default expiration, falling back to
|
205
|
+
# class default expiration if not set.
|
206
|
+
#
|
207
|
+
# @return [Float] The default expiration in seconds
|
208
|
+
#
|
36
209
|
def default_expiration
|
37
210
|
@default_expiration || self.class.default_expiration
|
38
211
|
end
|
39
212
|
|
40
|
-
# Sets an expiration time for the
|
213
|
+
# Sets an expiration time for the Redis/Valkey data associated with this object
|
41
214
|
#
|
42
215
|
# This method allows setting a Time To Live (TTL) for the data in Redis,
|
43
|
-
# after which it will be automatically removed.
|
216
|
+
# after which it will be automatically removed. The method also handles
|
217
|
+
# cascading expiration to related data structures when applicable.
|
44
218
|
#
|
45
|
-
# @param default_expiration [
|
46
|
-
# TTL will be used.
|
219
|
+
# @param default_expiration [Numeric, nil] The Time To Live in seconds. If nil,
|
220
|
+
# the default TTL will be used.
|
47
221
|
#
|
48
222
|
# @return [Boolean] Returns true if the expiration was set successfully,
|
49
223
|
# false otherwise.
|
@@ -51,18 +225,26 @@ module Familia
|
|
51
225
|
# @example Setting an expiration of one day
|
52
226
|
# object.update_expiration(default_expiration: 86400)
|
53
227
|
#
|
54
|
-
# @
|
55
|
-
#
|
228
|
+
# @example Using default expiration
|
229
|
+
# object.update_expiration # Uses class default_expiration
|
230
|
+
#
|
231
|
+
# @example Removing expiration (persist indefinitely)
|
232
|
+
# object.update_expiration(default_expiration: 0)
|
233
|
+
#
|
234
|
+
# @note If default expiration is set to zero, the expiration will be removed,
|
235
|
+
# making the data persist indefinitely.
|
56
236
|
#
|
57
|
-
# @raise [Familia::Problem] Raises an error if the default expiration is not
|
58
|
-
#
|
237
|
+
# @raise [Familia::Problem] Raises an error if the default expiration is not
|
238
|
+
# a non-negative number.
|
59
239
|
#
|
60
240
|
def update_expiration(default_expiration: nil)
|
61
241
|
default_expiration ||= self.default_expiration
|
62
242
|
|
63
|
-
|
243
|
+
# Handle cascading expiration to related data structures
|
244
|
+
if self.class.relations?
|
64
245
|
Familia.ld "[update_expiration] #{self.class} has relations: #{self.class.related_fields.keys}"
|
65
246
|
self.class.related_fields.each do |name, definition|
|
247
|
+
# Skip relations that don't have their own expiration settings
|
66
248
|
next if definition.opts[:default_expiration].nil?
|
67
249
|
|
68
250
|
obj = send(name)
|
@@ -71,30 +253,103 @@ module Familia
|
|
71
253
|
end
|
72
254
|
end
|
73
255
|
|
256
|
+
# Validate expiration value
|
74
257
|
# It's important to raise exceptions here and not just log warnings. We
|
75
258
|
# don't want to silently fail at setting expirations and cause data
|
76
259
|
# retention issues (e.g. not removed in a timely fashion).
|
77
|
-
#
|
78
|
-
# For the same reason, we don't want to default to 0 bc there's not a
|
79
|
-
# good reason for the default_expiration to not be set in the first place. If the
|
80
|
-
# class doesn't have a default_expiration, the default comes from
|
81
|
-
# Familia.default_expiration (which is 0, aka no-op/skip/do nothing).
|
82
260
|
unless default_expiration.is_a?(Numeric)
|
83
|
-
raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class}
|
261
|
+
raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} given for #{self.class})"
|
84
262
|
end
|
85
263
|
|
86
|
-
|
87
|
-
|
88
|
-
|
264
|
+
unless default_expiration >= 0
|
265
|
+
raise Familia::Problem, "Default expiration must be non-negative (#{default_expiration} given for #{self.class})"
|
266
|
+
end
|
267
|
+
|
268
|
+
# If zero, simply skip setting an expiry for this key. If we were to set
|
269
|
+
# 0, Redis would drop the key immediately.
|
270
|
+
if default_expiration.zero?
|
271
|
+
Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})"
|
272
|
+
return true
|
273
|
+
end
|
89
274
|
|
90
275
|
Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
|
91
276
|
|
92
277
|
# Redis' EXPIRE command returns 1 if the timeout was set, 0 if key does
|
93
|
-
# not exist or the timeout could not be set. Via redis-rb
|
94
|
-
# a bool.
|
278
|
+
# not exist or the timeout could not be set. Via redis-rb, it's a boolean.
|
95
279
|
expire(default_expiration)
|
96
280
|
end
|
97
281
|
|
282
|
+
# Get the remaining time to live for this object's data
|
283
|
+
#
|
284
|
+
# @return [Integer] Seconds remaining before expiration, or -1 if no TTL is set
|
285
|
+
#
|
286
|
+
# @example Check remaining TTL
|
287
|
+
# session.ttl # => 3599 (expires in ~1 hour)
|
288
|
+
# session.ttl.zero? # => false
|
289
|
+
#
|
290
|
+
# @example Check if expired or no TTL
|
291
|
+
# expired_session.ttl # => -1
|
292
|
+
#
|
293
|
+
def ttl
|
294
|
+
redis.ttl(dbkey)
|
295
|
+
end
|
296
|
+
|
297
|
+
# Check if this object's data will expire
|
298
|
+
#
|
299
|
+
# @return [Boolean] true if TTL is set, false if data persists indefinitely
|
300
|
+
#
|
301
|
+
def expires?
|
302
|
+
ttl > 0
|
303
|
+
end
|
304
|
+
|
305
|
+
# Check if this object's data has expired or will expire soon
|
306
|
+
#
|
307
|
+
# @param threshold [Numeric] Consider expired if TTL is below this threshold (default: 0)
|
308
|
+
# @return [Boolean] true if expired or expiring soon
|
309
|
+
#
|
310
|
+
# @example Check if expired
|
311
|
+
# session.expired? # => true if TTL <= 0
|
312
|
+
#
|
313
|
+
# @example Check if expiring within 5 minutes
|
314
|
+
# session.expired?(5.minutes) # => true if TTL <= 300
|
315
|
+
#
|
316
|
+
def expired?(threshold = 0)
|
317
|
+
current_ttl = ttl
|
318
|
+
return false if current_ttl == -1 # no expiration set
|
319
|
+
return true if current_ttl == -2 # key does not exist
|
320
|
+
current_ttl <= threshold
|
321
|
+
end
|
322
|
+
|
323
|
+
# Extend the expiration time by the specified duration
|
324
|
+
#
|
325
|
+
# This adds the given duration to the current TTL, effectively extending
|
326
|
+
# the object's lifetime without changing the default expiration setting.
|
327
|
+
#
|
328
|
+
# @param duration [Numeric] Additional time in seconds
|
329
|
+
# @return [Boolean] Success of the operation
|
330
|
+
#
|
331
|
+
# @example Extend session by 1 hour
|
332
|
+
# session.extend_expiration(1.hour)
|
333
|
+
#
|
334
|
+
def extend_expiration(duration)
|
335
|
+
current_ttl = ttl
|
336
|
+
return false if current_ttl < 0 # No current expiration set
|
337
|
+
|
338
|
+
new_ttl = current_ttl + duration.to_f
|
339
|
+
expire(new_ttl)
|
340
|
+
end
|
341
|
+
|
342
|
+
# Remove expiration, making the object persist indefinitely
|
343
|
+
#
|
344
|
+
# @return [Boolean] Success of the operation
|
345
|
+
#
|
346
|
+
# @example Make session persistent
|
347
|
+
# session.persist!
|
348
|
+
#
|
349
|
+
def persist!
|
350
|
+
redis.persist(dbkey)
|
351
|
+
end
|
352
|
+
|
98
353
|
Familia::Base.add_feature self, :expiration
|
99
354
|
end
|
100
355
|
end
|
@@ -109,22 +364,53 @@ module Familia
|
|
109
364
|
# Base implementation of update_expiration that maintains API compatibility
|
110
365
|
# with the :expiration feature's implementation.
|
111
366
|
#
|
112
|
-
# This is a no-op implementation that gets overridden by
|
113
|
-
#
|
114
|
-
# compatibility with the overriding implementations.
|
367
|
+
# This is a no-op implementation that gets overridden by the :expiration
|
368
|
+
# feature. It accepts an optional default_expiration parameter to maintain
|
369
|
+
# interface compatibility with the overriding implementations.
|
115
370
|
#
|
116
|
-
# @param default_expiration [
|
117
|
-
# @return [nil] Always returns nil
|
371
|
+
# @param default_expiration [Numeric, nil] Time To Live in seconds
|
372
|
+
# @return [nil] Always returns nil for the base implementation
|
118
373
|
#
|
119
374
|
# @note This is a no-op implementation. Classes that need expiration
|
120
375
|
# functionality should include the :expiration feature.
|
121
376
|
#
|
377
|
+
# @example Enable expiration feature
|
378
|
+
# class MyModel < Familia::Horreum
|
379
|
+
# feature :expiration
|
380
|
+
# default_expiration 1.hour
|
381
|
+
# end
|
382
|
+
#
|
122
383
|
def update_expiration(default_expiration: nil)
|
123
384
|
Familia.ld <<~LOG
|
124
|
-
[update_expiration]
|
385
|
+
[update_expiration] Expiration feature not enabled for #{self.class}.
|
125
386
|
Key: #{dbkey} Arg: #{default_expiration} (caller: #{caller(1..1)})
|
126
387
|
LOG
|
127
388
|
nil
|
128
389
|
end
|
390
|
+
|
391
|
+
# Base implementation of ttl that returns -1 (no expiration set)
|
392
|
+
#
|
393
|
+
# @return [Integer] Always returns -1 for the base implementation
|
394
|
+
#
|
395
|
+
def ttl
|
396
|
+
-1
|
397
|
+
end
|
398
|
+
|
399
|
+
# Base implementation of expires? that returns false
|
400
|
+
#
|
401
|
+
# @return [Boolean] Always returns false for the base implementation
|
402
|
+
#
|
403
|
+
def expires?
|
404
|
+
false
|
405
|
+
end
|
406
|
+
|
407
|
+
# Base implementation of expired? that returns false
|
408
|
+
#
|
409
|
+
# @param threshold [Numeric] Ignored in base implementation
|
410
|
+
# @return [Boolean] Always returns false for the base implementation
|
411
|
+
#
|
412
|
+
def expired?(_threshold = 0)
|
413
|
+
false
|
414
|
+
end
|
129
415
|
end
|
130
416
|
end
|