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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -13
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +2 -2
  9. data/docs/wiki/Feature-System-Guide.md +36 -5
  10. data/docs/wiki/Home.md +30 -20
  11. data/docs/wiki/Relationships-Guide.md +684 -0
  12. data/examples/bit_encoding_integration.rb +237 -0
  13. data/examples/redis_command_validation_example.rb +231 -0
  14. data/examples/relationships_basic.rb +273 -0
  15. data/lib/familia/connection.rb +3 -3
  16. data/lib/familia/data_type.rb +7 -4
  17. data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
  18. data/lib/familia/features/encrypted_fields.rb +413 -4
  19. data/lib/familia/features/expiration.rb +319 -33
  20. data/lib/familia/features/quantization.rb +385 -44
  21. data/lib/familia/features/relationships/cascading.rb +438 -0
  22. data/lib/familia/features/relationships/indexing.rb +370 -0
  23. data/lib/familia/features/relationships/membership.rb +503 -0
  24. data/lib/familia/features/relationships/permission_management.rb +264 -0
  25. data/lib/familia/features/relationships/querying.rb +620 -0
  26. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  27. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  28. data/lib/familia/features/relationships/tracking.rb +379 -0
  29. data/lib/familia/features/relationships.rb +466 -0
  30. data/lib/familia/features/transient_fields.rb +192 -10
  31. data/lib/familia/features.rb +2 -1
  32. data/lib/familia/horreum/subclass/definition.rb +1 -1
  33. data/lib/familia/validation/command_recorder.rb +336 -0
  34. data/lib/familia/validation/expectations.rb +519 -0
  35. data/lib/familia/validation/test_helpers.rb +443 -0
  36. data/lib/familia/validation/validator.rb +412 -0
  37. data/lib/familia/validation.rb +140 -0
  38. data/lib/familia/version.rb +1 -1
  39. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  40. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  41. data/try/edge_cases/string_coercion_try.rb +2 -0
  42. data/try/encryption/encryption_core_try.rb +3 -1
  43. data/try/features/categorical_permissions_try.rb +515 -0
  44. data/try/features/encryption_fields/concealed_string_core_try.rb +3 -0
  45. data/try/features/encryption_fields/context_isolation_try.rb +1 -0
  46. data/try/features/relationships_edge_cases_try.rb +145 -0
  47. data/try/features/relationships_performance_minimal_try.rb +132 -0
  48. data/try/features/relationships_performance_simple_try.rb +155 -0
  49. data/try/features/relationships_performance_try.rb +420 -0
  50. data/try/features/relationships_performance_working_try.rb +144 -0
  51. data/try/features/relationships_try.rb +237 -0
  52. data/try/features/safe_dump_try.rb +3 -0
  53. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  54. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  55. data/try/helpers/test_helpers.rb +1 -1
  56. data/try/horreum/base_try.rb +14 -8
  57. data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
  58. data/try/horreum/relations_try.rb +1 -1
  59. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  60. data/try/validation/command_validation_try.rb.disabled +207 -0
  61. data/try/validation/performance_validation_try.rb.disabled +324 -0
  62. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  63. metadata +32 -4
  64. data/docs/wiki/RelatableObjects-Guide.md +0 -563
  65. data/lib/familia/features/relatable_objects.rb +0 -125
  66. data/try/features/relatable_objects_try.rb +0 -220
@@ -2,48 +2,222 @@
2
2
 
3
3
  module Familia
4
4
  module Features
5
- # Famnilia::Features::Expiration
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!, nil, self, caller(1..1) if Familia.debug?
153
+ Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
12
154
  base.extend ClassMethods
13
155
 
14
- # Optionally define default_expiration in the class to make
15
- # sure we always have an array to work with.
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) # set above
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 Database data associated with this object.
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 [Integer, nil] The Time To Live in seconds. If nil, the default
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
- # @note If Default expiration is set to zero, the expiration will be removed, making the
55
- # data persist indefinitely.
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 a non-negative
58
- # integer.
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
- if self.class.has_relations?
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} in #{self.class})"
261
+ raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} given for #{self.class})"
84
262
  end
85
263
 
86
- # If zero, simply skips setting an expiry for this key. If we were to set
87
- # 0 the database would drop the key immediately.
88
- return Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})" if default_expiration.zero?
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 here, it's
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 features like
113
- # :expiration. It accepts an optional default_expiration parameter to maintain interface
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 [Integer, nil] Time To Live in seconds
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] Feature not enabled for #{self.class}.
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