familia 2.0.0.pre6 → 2.0.0.pre8

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 (96) 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 +3 -3
  9. data/README.md +35 -0
  10. data/docs/wiki/Feature-System-Guide.md +36 -20
  11. data/docs/wiki/Home.md +30 -20
  12. data/docs/wiki/Relationships-Guide.md +684 -0
  13. data/examples/bit_encoding_integration.rb +237 -0
  14. data/examples/redis_command_validation_example.rb +231 -0
  15. data/examples/relationships_basic.rb +273 -0
  16. data/lib/familia/connection.rb +3 -3
  17. data/lib/familia/data_type.rb +7 -4
  18. data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
  19. data/lib/familia/features/encrypted_fields.rb +413 -4
  20. data/lib/familia/features/expiration.rb +319 -33
  21. data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +120 -0
  22. data/lib/familia/features/external_identifiers.rb +111 -0
  23. data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +91 -0
  24. data/lib/familia/features/object_identifiers.rb +194 -0
  25. data/lib/familia/features/quantization.rb +385 -44
  26. data/lib/familia/features/relationships/cascading.rb +437 -0
  27. data/lib/familia/features/relationships/indexing.rb +369 -0
  28. data/lib/familia/features/relationships/membership.rb +502 -0
  29. data/lib/familia/features/relationships/permission_management.rb +264 -0
  30. data/lib/familia/features/relationships/querying.rb +615 -0
  31. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  32. data/lib/familia/features/relationships/score_encoding.rb +440 -0
  33. data/lib/familia/features/relationships/tracking.rb +378 -0
  34. data/lib/familia/features/relationships.rb +466 -0
  35. data/lib/familia/features/transient_fields.rb +190 -10
  36. data/lib/familia/features.rb +18 -14
  37. data/lib/familia/horreum/core/serialization.rb +2 -5
  38. data/lib/familia/horreum/subclass/definition.rb +35 -1
  39. data/lib/familia/validation/command_recorder.rb +336 -0
  40. data/lib/familia/validation/expectations.rb +519 -0
  41. data/lib/familia/validation/test_helpers.rb +443 -0
  42. data/lib/familia/validation/validator.rb +412 -0
  43. data/lib/familia/validation.rb +140 -0
  44. data/lib/familia/version.rb +1 -3
  45. data/try/core/errors_try.rb +1 -1
  46. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  47. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  48. data/try/edge_cases/string_coercion_try.rb +2 -0
  49. data/try/encryption/encryption_core_try.rb +3 -1
  50. data/try/features/{encryption_fields → encrypted_fields}/concealed_string_core_try.rb +3 -0
  51. data/try/features/{encryption_fields → encrypted_fields}/context_isolation_try.rb +1 -0
  52. data/try/features/{encrypted_fields_core_try.rb → encrypted_fields/encrypted_fields_core_try.rb} +1 -1
  53. data/try/features/{encrypted_fields_integration_try.rb → encrypted_fields/encrypted_fields_integration_try.rb} +1 -1
  54. data/try/features/{encrypted_fields_no_cache_security_try.rb → encrypted_fields/encrypted_fields_no_cache_security_try.rb} +1 -1
  55. data/try/features/{encrypted_fields_security_try.rb → encrypted_fields/encrypted_fields_security_try.rb} +1 -1
  56. data/try/features/{expiration_try.rb → expiration/expiration_try.rb} +1 -1
  57. data/try/features/external_identifiers/external_identifiers_try.rb +203 -0
  58. data/try/features/object_identifiers/object_identifiers_integration_try.rb +289 -0
  59. data/try/features/object_identifiers/object_identifiers_try.rb +191 -0
  60. data/try/features/{quantization_try.rb → quantization/quantization_try.rb} +1 -1
  61. data/try/features/relationships/categorical_permissions_try.rb +515 -0
  62. data/try/features/relationships/relationships_edge_cases_try.rb +145 -0
  63. data/try/features/relationships/relationships_performance_minimal_try.rb +132 -0
  64. data/try/features/relationships/relationships_performance_simple_try.rb +155 -0
  65. data/try/features/relationships/relationships_performance_try.rb +420 -0
  66. data/try/features/relationships/relationships_performance_working_try.rb +144 -0
  67. data/try/features/relationships/relationships_try.rb +237 -0
  68. data/try/features/{safe_dump_advanced_try.rb → safe_dump/safe_dump_advanced_try.rb} +1 -1
  69. data/try/features/{safe_dump_try.rb → safe_dump/safe_dump_try.rb} +4 -1
  70. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  71. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  72. data/try/features/{transient_fields_core_try.rb → transient_fields/transient_fields_core_try.rb} +1 -1
  73. data/try/features/{transient_fields_integration_try.rb → transient_fields/transient_fields_integration_try.rb} +1 -1
  74. data/try/helpers/test_helpers.rb +1 -1
  75. data/try/horreum/base_try.rb +14 -8
  76. data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
  77. data/try/horreum/relations_try.rb +1 -1
  78. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  79. data/try/validation/command_validation_try.rb.disabled +207 -0
  80. data/try/validation/performance_validation_try.rb.disabled +324 -0
  81. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  82. metadata +62 -27
  83. data/docs/wiki/RelatableObjects-Guide.md +0 -563
  84. data/lib/familia/features/relatable_objects.rb +0 -125
  85. data/try/features/relatable_objects_try.rb +0 -220
  86. /data/try/features/{encryption_fields → encrypted_fields}/aad_protection_try.rb +0 -0
  87. /data/try/features/{encryption_fields → encrypted_fields}/error_conditions_try.rb +0 -0
  88. /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_derivation_try.rb +0 -0
  89. /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_try.rb +0 -0
  90. /data/try/features/{encryption_fields → encrypted_fields}/key_rotation_try.rb +0 -0
  91. /data/try/features/{encryption_fields → encrypted_fields}/memory_security_try.rb +0 -0
  92. /data/try/features/{encryption_fields → encrypted_fields}/missing_current_key_version_try.rb +0 -0
  93. /data/try/features/{encryption_fields → encrypted_fields}/nonce_uniqueness_try.rb +0 -0
  94. /data/try/features/{encryption_fields → encrypted_fields}/secure_by_default_behavior_try.rb +0 -0
  95. /data/try/features/{encryption_fields → encrypted_fields}/thread_safety_try.rb +0 -0
  96. /data/try/features/{encryption_fields → encrypted_fields}/universal_serialization_safety_try.rb +0 -0
@@ -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
@@ -0,0 +1,120 @@
1
+ # lib/familia/features/external_identifiers/external_identifier_field_type.rb
2
+
3
+ require 'familia/field_type'
4
+
5
+ module Familia
6
+ module Features
7
+ module ExternalIdentifiers
8
+ # ExternalIdentifierFieldType - Fields that generate deterministic external identifiers
9
+ #
10
+ # External identifier fields generate shorter, public-facing identifiers that are
11
+ # deterministically derived from object identifiers. These IDs are safe for use
12
+ # in URLs, APIs, and other external contexts where shorter IDs are preferred.
13
+ #
14
+ # Key characteristics:
15
+ # - Deterministic generation from objid ensures consistency
16
+ # - Shorter than objid (128-bit vs 256-bit) for external use
17
+ # - Base-36 encoding for URL-safe identifiers
18
+ # - 'ext_' prefix for clear identification as external IDs
19
+ # - Lazy generation preserves values from initialization
20
+ #
21
+ # @example Using external identifier fields
22
+ # class User < Familia::Horreum
23
+ # feature :object_identifiers
24
+ # feature :external_identifiers
25
+ # field :email
26
+ # end
27
+ #
28
+ # user = User.new(email: 'user@example.com')
29
+ # user.objid # => "01234567-89ab-7def-8000-123456789abc"
30
+ # user.extid # => "ext_abc123def456ghi789" (deterministic from objid)
31
+ #
32
+ # # Same objid always produces same extid
33
+ # user2 = User.new(objid: user.objid, email: 'user@example.com')
34
+ # user2.extid # => "ext_abc123def456ghi789" (identical to user.extid)
35
+ #
36
+ class ExternalIdentifierFieldType < FieldType
37
+ # Override getter to provide lazy generation from objid
38
+ #
39
+ # Generates the external identifier deterministically from the object's
40
+ # objid. This ensures consistency - the same objid will always produce
41
+ # the same extid. Only generates when objid is available.
42
+ #
43
+ # @param klass [Class] The class to define the method on
44
+ #
45
+ def define_getter(klass)
46
+ field_name = @name
47
+ method_name = @method_name
48
+
49
+ handle_method_conflict(klass, method_name) do
50
+ klass.define_method method_name do
51
+ # Check if we already have a value (from initialization or previous generation)
52
+ existing_value = instance_variable_get(:"@#{field_name}")
53
+ return existing_value unless existing_value.nil?
54
+
55
+ # Generate external identifier from objid if available
56
+ generated_extid = generate_external_identifier
57
+ return unless generated_extid
58
+
59
+ instance_variable_set(:"@#{field_name}", generated_extid)
60
+
61
+ # Update mapping if we have an identifier
62
+ if respond_to?(:identifier) && identifier
63
+ self.class.extid_lookup[generated_extid] = identifier
64
+ end
65
+
66
+ generated_extid
67
+ end
68
+ end
69
+ end
70
+
71
+ # Override setter to preserve values during initialization
72
+ #
73
+ # This ensures that values passed during object initialization
74
+ # (e.g., when loading from Redis) are preserved and not overwritten
75
+ # by the lazy generation logic.
76
+ #
77
+ # @param klass [Class] The class to define the method on
78
+ #
79
+ def define_setter(klass)
80
+ field_name = @name
81
+ method_name = @method_name
82
+
83
+ handle_method_conflict(klass, :"#{method_name}=") do
84
+ klass.define_method :"#{method_name}=" do |value|
85
+ # Remove old mapping if extid is changing
86
+ old_value = instance_variable_get(:"@#{field_name}")
87
+ if old_value && old_value != value && respond_to?(:identifier)
88
+ self.class.extid_lookup.del(old_value)
89
+ end
90
+
91
+ # Set the new value
92
+ instance_variable_set(:"@#{field_name}", value)
93
+
94
+ # Update mapping if we have both extid and identifier
95
+ if value && respond_to?(:identifier) && identifier
96
+ self.class.extid_lookup[value] = identifier
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ # External identifier fields are persisted to database
103
+ #
104
+ # @return [Boolean] true - external identifiers are always persisted
105
+ #
106
+ def persistent?
107
+ true
108
+ end
109
+
110
+ # Category for external identifier fields
111
+ #
112
+ # @return [Symbol] :external_identifier
113
+ #
114
+ def category
115
+ :external_identifier
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,111 @@
1
+ # lib/familia/features/external_identifiers.rb
2
+
3
+ require_relative 'external_identifiers/external_identifier_field_type'
4
+
5
+ module Familia
6
+ module Features
7
+
8
+ # Familia::Features::ExternalIdentifiers
9
+ #
10
+ module ExternalIdentifiers
11
+ def self.included(base)
12
+ Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
13
+ base.extend ClassMethods
14
+
15
+ # Ensure default prefix is set in feature options
16
+ base.add_feature_options(:external_identifiers, prefix: 'ext')
17
+
18
+ # Add class-level mapping for extid -> id lookups
19
+ base.class_hashkey :extid_lookup
20
+
21
+ # Register the extid field using our custom field type
22
+ base.register_field_type(
23
+ ExternalIdentifiers::ExternalIdentifierFieldType.new(:extid, as: :extid, fast_method: false)
24
+ )
25
+ end
26
+
27
+ # ExternalIdentifiers::ClassMethods
28
+ #
29
+ module ClassMethods
30
+ def generate_extid(objid = nil)
31
+ unless features_enabled.include?(:object_identifiers)
32
+ raise Familia::Problem,
33
+ 'ExternalIdentifiers requires ObjectIdentifiers feature'
34
+ end
35
+ return nil if objid.to_s.empty?
36
+
37
+ objid_hex = objid.to_s.delete('-')
38
+ external_part = Familia.shorten_to_external_id(objid_hex, base: 36)
39
+ prefix = feature_options(:external_identifiers)[:prefix] || 'ext'
40
+ "#{prefix}_#{external_part}"
41
+ end
42
+
43
+ # Find an object by its external identifier
44
+ #
45
+ # @param extid [String] The external identifier to search for
46
+ # @return [Object, nil] The object if found, nil otherwise
47
+ #
48
+ def find_by_extid(extid)
49
+ return nil if extid.to_s.empty?
50
+
51
+ if Familia.debug?
52
+ reference = caller(1..1).first
53
+ Familia.trace :FIND_BY_EXTID, Familia.dbclient, extid, reference
54
+ end
55
+
56
+ # Look up the primary ID from the external ID mapping
57
+ primary_id = extid_lookup[extid]
58
+ return nil if primary_id.nil?
59
+
60
+ # Find the object by its primary ID
61
+ find_by_id(primary_id)
62
+ rescue Familia::NotFound
63
+ # If the object was deleted but mapping wasn't cleaned up
64
+ extid_lookup.del(extid)
65
+ nil
66
+ end
67
+ end
68
+
69
+ # Generate external identifier deterministically from objid
70
+ def generate_external_identifier
71
+ return nil unless respond_to?(:objid)
72
+
73
+ current_objid = objid
74
+ return nil if current_objid.nil? || current_objid.to_s.empty?
75
+
76
+ # Convert objid to hex string for processing
77
+ objid_hex = current_objid.delete('-') # Remove UUID hyphens if present
78
+
79
+ # Generate deterministic external ID using SecureIdentifier
80
+ external_part = Familia.shorten_to_external_id(objid_hex, base: 36)
81
+
82
+ # Get prefix from feature options, default to "ext"
83
+ options = self.class.feature_options(:external_identifiers)
84
+ prefix = options[:prefix] || 'ext'
85
+
86
+ "#{prefix}_#{external_part}"
87
+ end
88
+
89
+ def external_identifier
90
+ extid
91
+ end
92
+
93
+ def init
94
+ super if defined?(super)
95
+ # External IDs are generated from objid, so no additional setup needed
96
+ end
97
+
98
+ def destroy!
99
+ # Clean up extid mapping when object is destroyed
100
+ current_extid = instance_variable_get(:@extid)
101
+ if current_extid
102
+ self.class.extid_lookup.del(current_extid)
103
+ end
104
+
105
+ super if defined?(super)
106
+ end
107
+
108
+ Familia::Base.add_feature self, :external_identifiers, depends_on: [:object_identifiers]
109
+ end
110
+ end
111
+ end