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
@@ -0,0 +1,466 @@
1
+ # lib/familia/features/relationships.rb
2
+
3
+ require 'securerandom'
4
+ require_relative 'relationships/score_encoding'
5
+ require_relative 'relationships/redis_operations'
6
+ require_relative 'relationships/tracking'
7
+ require_relative 'relationships/indexing'
8
+ require_relative 'relationships/membership'
9
+ require_relative 'relationships/cascading'
10
+ require_relative 'relationships/querying'
11
+ require_relative 'relationships/permission_management'
12
+
13
+ module Familia
14
+ module Features
15
+ # Unified Relationships feature for Familia v2
16
+ #
17
+ # This feature merges the functionality of relatable_objects and relationships
18
+ # into a single, Redis-native implementation that embraces the "where does this appear?"
19
+ # philosophy rather than "who owns this?".
20
+ #
21
+ # Key improvements in v2:
22
+ # - Multi-presence: Objects can exist in multiple collections simultaneously
23
+ # - Score encoding: Metadata embedded in Redis scores for efficiency
24
+ # - Collision-free: Method names include collection names to prevent conflicts
25
+ # - Redis-native: All operations use Redis commands, no Ruby iteration
26
+ # - Atomic operations: Multi-collection updates happen atomically
27
+ #
28
+ # Breaking changes from v1:
29
+ # - Single feature: Use `feature :relationships` instead of separate features
30
+ # - Simplified identifier: Use `identifier :field` instead of `identifier_field :field`
31
+ # - No ownership concept: Remove `owned_by`, use multi-presence instead
32
+ # - Method naming: Generated methods include collection names for uniqueness
33
+ # - Score encoding: Scores can carry metadata like permissions
34
+ #
35
+ # @example Basic usage
36
+ # class Domain < Familia::Horreum
37
+ # feature :relationships
38
+ #
39
+ # identifier :domain_id
40
+ # field :domain_id
41
+ # field :display_name
42
+ # field :created_at
43
+ # field :permission_bits
44
+ #
45
+ # # Multi-presence tracking with score encoding
46
+ # tracked_in Customer, :domains,
47
+ # score: -> { permission_encode(created_at, permission_bits) }
48
+ # tracked_in Team, :domains, score: :added_at
49
+ # tracked_in Organization, :all_domains, score: :created_at
50
+ #
51
+ # # O(1) lookups with Redis hashes
52
+ # indexed_by :display_name, in: Customer, index_name: :domain_index
53
+ # indexed_by :display_name, in: :global, index_name: :global_domain_index
54
+ #
55
+ # # Context-aware membership (no method collisions)
56
+ # member_of Customer, :domains
57
+ # member_of Team, :domains
58
+ # member_of Organization, :domains
59
+ # end
60
+ #
61
+ # @example Generated methods (collision-free)
62
+ # # Tracking methods
63
+ # Customer.domains # => Familia::SortedSet
64
+ # Customer.add_domain(domain, score) # Add to customer's domains
65
+ # domain.in_customer_domains?(customer) # Check membership
66
+ #
67
+ # # Indexing methods
68
+ # Customer.find_by_display_name(name) # O(1) lookup
69
+ # Domain.find_by_display_name_globally(name) # Global lookup
70
+ #
71
+ # # Membership methods (collision-free naming)
72
+ # domain.add_to_customer_domains(customer) # Specific collection
73
+ # domain.add_to_team_domains(team) # Different collection
74
+ # domain.in_customer_domains?(customer) # Check specific membership
75
+ #
76
+ # @example Score encoding for permissions
77
+ # # Encode permission in score
78
+ # score = domain.permission_encode(Time.now, :write)
79
+ # # => 1704067200.004 (timestamp + permission bits)
80
+ #
81
+ # # Decode permission from score
82
+ # decoded = domain.permission_decode(score)
83
+ # # => { timestamp: 1704067200, permissions: 4, permission_list: [:write] }
84
+ #
85
+ # # Query with permission filtering
86
+ # Customer.domains_with_permission(:read)
87
+ #
88
+ # @example Multi-collection operations
89
+ # # Atomic updates across multiple collections
90
+ # domain.update_multiple_presence([
91
+ # { key: "customer:123:domains", score: current_score },
92
+ # { key: "team:456:domains", score: permission_encode(Time.now, :read) }
93
+ # ], :add, domain.identifier)
94
+ #
95
+ # # Set operations on collections
96
+ # accessible = Domain.union_collections([
97
+ # { owner: customer, collection: :domains },
98
+ # { owner: team, collection: :domains }
99
+ # ], min_permission: :read)
100
+ module Relationships
101
+ # Feature initialization
102
+ def self.included(base)
103
+ puts "[DEBUG] Relationships included in #{base}"
104
+ base.extend ClassMethods
105
+ base.include InstanceMethods
106
+
107
+ # Include all relationship submodules and their class methods
108
+ base.include ScoreEncoding
109
+ base.include RedisOperations
110
+
111
+ puts '[DEBUG] Including Tracking module'
112
+ base.include Tracking
113
+ puts '[DEBUG] Extending with Tracking::ClassMethods'
114
+ base.extend Tracking::ClassMethods
115
+ puts "[DEBUG] Base now responds to tracked_in: #{base.respond_to?(:tracked_in)}"
116
+
117
+ base.include Indexing
118
+ base.extend Indexing::ClassMethods
119
+
120
+ base.include Membership
121
+ base.extend Membership::ClassMethods
122
+
123
+ base.include Cascading
124
+ base.extend Cascading::ClassMethods
125
+
126
+ base.include Querying
127
+ base.extend Querying::ClassMethods
128
+ end
129
+
130
+ # Error classes
131
+ class RelationshipError < StandardError; end
132
+ class InvalidIdentifierError < RelationshipError; end
133
+ class InvalidScoreError < RelationshipError; end
134
+ class CascadeError < RelationshipError; end
135
+
136
+ module ClassMethods
137
+ # Define the identifier for this class (replaces identifier_field)
138
+ # This is a compatibility wrapper around the existing identifier_field method
139
+ #
140
+ # @param field [Symbol] The field to use as identifier
141
+ # @return [Symbol] The identifier field
142
+ #
143
+ # @example
144
+ # identifier :domain_id
145
+ def identifier(field = nil)
146
+ return identifier_field(field) if field
147
+
148
+ identifier_field
149
+ end
150
+
151
+ # Generate a secure temporary identifier
152
+ def generate_identifier
153
+ SecureRandom.hex(8)
154
+ end
155
+
156
+ # Get all relationship configurations for this class
157
+ def relationship_configs
158
+ configs = {}
159
+
160
+ configs[:tracking] = tracking_relationships if respond_to?(:tracking_relationships)
161
+ configs[:indexing] = indexing_relationships if respond_to?(:indexing_relationships)
162
+ configs[:membership] = membership_relationships if respond_to?(:membership_relationships)
163
+
164
+ configs
165
+ end
166
+
167
+ # Validate relationship configurations
168
+ def validate_relationships!
169
+ errors = []
170
+
171
+ # Check for method name collisions
172
+ method_names = []
173
+
174
+ if respond_to?(:tracking_relationships)
175
+ tracking_relationships.each do |config|
176
+ context_name = config[:context_class_name].downcase
177
+ collection_name = config[:collection_name]
178
+
179
+ method_names << "in_#{context_name}_#{collection_name}?"
180
+ method_names << "add_to_#{context_name}_#{collection_name}"
181
+ method_names << "remove_from_#{context_name}_#{collection_name}"
182
+ end
183
+ end
184
+
185
+ if respond_to?(:membership_relationships)
186
+ membership_relationships.each do |config|
187
+ owner_name = config[:owner_class_name].downcase
188
+ collection_name = config[:collection_name]
189
+
190
+ method_names << "in_#{owner_name}_#{collection_name}?"
191
+ method_names << "add_to_#{owner_name}_#{collection_name}"
192
+ method_names << "remove_from_#{owner_name}_#{collection_name}"
193
+ end
194
+ end
195
+
196
+ # Check for duplicates
197
+ duplicates = method_names.group_by(&:itself).select { |_, v| v.size > 1 }.keys
198
+ errors << "Method name collisions detected: #{duplicates.join(', ')}" if duplicates.any?
199
+
200
+ # Validate identifier field exists
201
+ id_field = identifier
202
+ unless instance_methods.include?(id_field) || method_defined?(id_field)
203
+ errors << "Identifier field '#{id_field}' is not defined"
204
+ end
205
+
206
+ raise RelationshipError, "Relationship validation failed: #{errors.join('; ')}" if errors.any?
207
+
208
+ true
209
+ end
210
+
211
+ # Create a new instance with relationships initialized
212
+ def create_with_relationships(attributes = {})
213
+ instance = new(attributes)
214
+ instance.initialize_relationships
215
+ instance
216
+ end
217
+
218
+ # Class method wrapper for create_temp_key
219
+ def create_temp_key(base_name, ttl = 300)
220
+ timestamp = Time.now.to_i
221
+ random_suffix = SecureRandom.hex(3)
222
+ temp_key = "temp:#{base_name}:#{timestamp}:#{random_suffix}"
223
+
224
+ # Set immediate expiry to ensure cleanup even if operation fails
225
+ if respond_to?(:dbclient)
226
+ dbclient.expire(temp_key, ttl)
227
+ else
228
+ Familia.dbclient.expire(temp_key, ttl)
229
+ end
230
+
231
+ temp_key
232
+ end
233
+
234
+ # Include core score encoding methods at class level
235
+ include ScoreEncoding
236
+
237
+ private
238
+
239
+ # Simple constantize method to convert string to constant
240
+ def constantize_class_name(class_name)
241
+ class_name.split('::').reduce(Object) { |mod, name| mod.const_get(name) }
242
+ rescue NameError
243
+ # If the class doesn't exist, return nil
244
+ nil
245
+ end
246
+ end
247
+
248
+ module InstanceMethods
249
+ # Get the identifier value for this instance
250
+ # Uses the existing Horreum identifier infrastructure
251
+ def identifier
252
+ id_field = self.class.identifier_field
253
+ send(id_field) if respond_to?(id_field)
254
+ end
255
+
256
+ # Set the identifier value for this instance
257
+ def identifier=(value)
258
+ id_field = self.class.identifier_field
259
+ send("#{id_field}=", value) if respond_to?("#{id_field}=")
260
+ end
261
+
262
+ # Initialize relationships (called after object creation)
263
+ def initialize_relationships
264
+ # This can be overridden by subclasses to set up initial relationships
265
+ end
266
+
267
+ # Override save to update relationships
268
+ def save(update_expiration: true)
269
+ result = super
270
+
271
+ if result && respond_to?(:update_all_indexes)
272
+ # Update all indexes with current field values
273
+ update_all_indexes
274
+
275
+ # NOTE: Tracking and membership updates are typically done explicitly
276
+ # since we need to know which specific collections this object should be in
277
+ end
278
+
279
+ result
280
+ end
281
+
282
+ # Override destroy to handle cascade operations
283
+ def destroy!
284
+ # Execute cascade operations before destroying the object
285
+ execute_cascade_operations if respond_to?(:execute_cascade_operations)
286
+
287
+ super
288
+ end
289
+
290
+ # Get comprehensive relationship status for this object
291
+ def relationship_status
292
+ status = {
293
+ identifier: identifier,
294
+ tracking_memberships: [],
295
+ membership_collections: [],
296
+ index_memberships: []
297
+ }
298
+
299
+ # Get tracking memberships
300
+ if respond_to?(:tracking_collections_membership)
301
+ status[:tracking_memberships] = tracking_collections_membership
302
+ end
303
+
304
+ # Get membership collections
305
+ status[:membership_collections] = membership_collections if respond_to?(:membership_collections)
306
+
307
+ # Get index memberships
308
+ status[:index_memberships] = indexing_memberships if respond_to?(:indexing_memberships)
309
+
310
+ status
311
+ end
312
+
313
+ # Comprehensive cleanup - remove from all relationships
314
+ def cleanup_all_relationships!
315
+ # Remove from tracking collections
316
+ remove_from_all_tracking_collections if respond_to?(:remove_from_all_tracking_collections)
317
+
318
+ # Remove from membership collections
319
+ remove_from_all_memberships if respond_to?(:remove_from_all_memberships)
320
+
321
+ # Remove from indexes
322
+ remove_from_all_indexes if respond_to?(:remove_from_all_indexes)
323
+ end
324
+
325
+ # Dry run for relationship cleanup (preview what would be affected)
326
+ def cleanup_preview
327
+ preview = {
328
+ tracking_collections: [],
329
+ membership_collections: [],
330
+ index_entries: []
331
+ }
332
+
333
+ if respond_to?(:cascade_dry_run)
334
+ cascade_preview = cascade_dry_run
335
+ preview.merge!(cascade_preview)
336
+ end
337
+
338
+ preview
339
+ end
340
+
341
+ # Validate that this object's relationships are consistent
342
+ def validate_relationships!
343
+ errors = []
344
+
345
+ # Validate identifier exists
346
+ errors << 'Object identifier is nil' unless identifier
347
+
348
+ # Validate tracking memberships
349
+ if respond_to?(:tracking_collections_membership)
350
+ tracking_collections_membership.each do |membership|
351
+ score = membership[:score]
352
+ errors << "Invalid score in tracking membership: #{membership}" if score && !score.is_a?(Numeric)
353
+ end
354
+ end
355
+
356
+ raise RelationshipError, "Relationship validation failed for #{self}: #{errors.join('; ')}" if errors.any?
357
+
358
+ true
359
+ end
360
+
361
+ # Refresh relationship data from Redis (useful after external changes)
362
+ def refresh_relationships!
363
+ # Clear any cached relationship data
364
+ @relationship_status = nil
365
+ @tracking_memberships = nil
366
+ @membership_collections = nil
367
+ @index_memberships = nil
368
+
369
+ # Reload fresh data
370
+ relationship_status
371
+ end
372
+
373
+ # Create a snapshot of current relationship state (for debugging)
374
+ def relationship_snapshot
375
+ {
376
+ timestamp: Time.now,
377
+ identifier: identifier,
378
+ class: self.class.name,
379
+ status: relationship_status,
380
+ redis_keys: find_related_redis_keys
381
+ }
382
+ end
383
+
384
+ # Direct Redis access for instance methods
385
+ def redis
386
+ self.class.dbclient
387
+ end
388
+
389
+ # Instance method wrapper for create_temp_key
390
+ def create_temp_key(base_name, ttl = 300)
391
+ timestamp = Time.now.to_i
392
+ random_suffix = SecureRandom.hex(3)
393
+ temp_key = "temp:#{base_name}:#{timestamp}:#{random_suffix}"
394
+
395
+ # Set immediate expiry to ensure cleanup even if operation fails
396
+ redis.expire(temp_key, ttl)
397
+
398
+ temp_key
399
+ end
400
+
401
+ # Instance method wrapper for cleanup_temp_keys
402
+ def cleanup_temp_keys(pattern = 'temp:*', batch_size = 100)
403
+ cursor = 0
404
+
405
+ loop do
406
+ cursor, keys = redis.scan(cursor, match: pattern, count: batch_size)
407
+
408
+ if keys.any?
409
+ # Check TTL and remove keys that should have expired
410
+ keys.each_slice(batch_size) do |key_batch|
411
+ redis.pipelined do |pipeline|
412
+ key_batch.each do |key|
413
+ ttl = redis.ttl(key)
414
+ pipeline.del(key) if ttl == -1 # Key exists but has no TTL
415
+ end
416
+ end
417
+ end
418
+ end
419
+
420
+ break if cursor.zero?
421
+ end
422
+ end
423
+
424
+ private
425
+
426
+ # Find all Redis keys related to this object
427
+ def find_related_redis_keys
428
+ related_keys = []
429
+ id = identifier
430
+ return related_keys unless id
431
+
432
+ # Scan for keys that might contain this object
433
+ patterns = [
434
+ '*:*:*', # General pattern for relationship keys
435
+ "*#{id}*" # Keys containing the identifier
436
+ ]
437
+
438
+ patterns.each do |pattern|
439
+ redis.scan_each(match: pattern, count: 100) do |key|
440
+ # Check if this key actually contains our object
441
+ key_type = redis.type(key)
442
+
443
+ case key_type
444
+ when 'zset'
445
+ related_keys << key if redis.zscore(key, id)
446
+ when 'set'
447
+ related_keys << key if redis.sismember(key, id)
448
+ when 'list'
449
+ related_keys << key if redis.lpos(key, id)
450
+ when 'hash'
451
+ # For hash keys, check if any field values match our identifier
452
+ hash_values = redis.hvals(key)
453
+ related_keys << key if hash_values.include?(id.to_s)
454
+ end
455
+ end
456
+ end
457
+
458
+ related_keys.uniq
459
+ end
460
+ end
461
+
462
+ # Register the feature with Familia
463
+ Familia::Base.add_feature Relationships, :relationships
464
+ end
465
+ end
466
+ end
@@ -4,23 +4,120 @@ require_relative 'transient_fields/redacted_string'
4
4
 
5
5
  module Familia
6
6
  module Features
7
- # Familia::Features::TransientFields
7
+ # TransientFields is a feature that provides secure handling of sensitive runtime data
8
+ # that should never be persisted to Redis/Valkey. Unlike encrypted fields, transient
9
+ # fields exist only in memory and are automatically wrapped in RedactedString objects
10
+ # for security.
8
11
  #
9
- # Provides secure transient fields that wrap sensitive values in RedactedString
10
- # objects. These fields are excluded from serialization operations and provide
11
- # automatic memory wiping for security.
12
+ # Transient fields are ideal for:
13
+ # - API keys and tokens that change frequently
14
+ # - Temporary passwords or passphrases
15
+ # - Session-specific secrets
16
+ # - Any sensitive data that should never touch persistent storage
17
+ # - Debug or development secrets that need secure handling
18
+ #
19
+ # All transient field values are automatically wrapped in RedactedString instances
20
+ # which provide:
21
+ # - Automatic redaction in logs and string representations
22
+ # - Secure memory management with explicit cleanup
23
+ # - Safe access patterns through expose blocks
24
+ # - Protection against accidental value exposure
25
+ #
26
+ # Example:
27
+ #
28
+ # class ApiClient < Familia::Horreum
29
+ # feature :transient_fields
30
+ #
31
+ # field :endpoint # Regular persistent field
32
+ # transient_field :token # Transient field (not persisted)
33
+ # transient_field :secret, as: :api_secret # Custom accessor name
34
+ # end
35
+ #
36
+ # client = ApiClient.new(
37
+ # endpoint: 'https://api.example.com',
38
+ # token: ENV['API_TOKEN'],
39
+ # secret: ENV['API_SECRET']
40
+ # )
41
+ #
42
+ # # Regular field persists
43
+ # client.save
44
+ # client.endpoint # => "https://api.example.com"
45
+ #
46
+ # # Transient fields are RedactedString instances
47
+ # puts client.token # => "[REDACTED]"
48
+ #
49
+ # # Access the actual value safely
50
+ # client.token.expose do |token|
51
+ # response = HTTP.post(client.endpoint,
52
+ # headers: { 'Authorization' => "Bearer #{token}" }
53
+ # )
54
+ # # Token value is only available within this block
55
+ # end
56
+ #
57
+ # # Explicit cleanup when done
58
+ # client.token.clear!
59
+ #
60
+ # Security Features:
61
+ #
62
+ # RedactedString automatically protects sensitive values:
63
+ # - String representation shows "[REDACTED]" instead of actual value
64
+ # - Inspect output shows "[REDACTED]" instead of actual value
65
+ # - Hash values are constant to prevent value inference
66
+ # - Equality checks work only on object identity
67
+ #
68
+ # Safe Access Patterns:
69
+ #
70
+ # # ✅ Recommended: Use .expose block
71
+ # client.token.expose do |token|
72
+ # # Use token directly without creating copies
73
+ # HTTP.auth("Bearer #{token}") # Safe
74
+ # end
75
+ #
76
+ # # ✅ Direct access (use carefully)
77
+ # raw_token = client.token.value
78
+ # # Remember to clear original source if needed
79
+ #
80
+ # # ❌ Avoid: These create uncontrolled copies
81
+ # token_copy = client.token.value.dup # Creates copy in memory
82
+ # interpolated = "Bearer #{client.token}" # Creates copy via to_s
83
+ #
84
+ # Memory Management:
85
+ #
86
+ # # Clear individual fields
87
+ # client.token.clear!
88
+ #
89
+ # # Check if cleared
90
+ # client.token.cleared? # => true
91
+ #
92
+ # # Accessing cleared values raises error
93
+ # client.token.value # => SecurityError: Value already cleared
94
+ #
95
+ # ⚠️ Important Security Limitations:
96
+ #
97
+ # Ruby provides NO memory safety guarantees for cryptographic secrets:
98
+ # - No secure wiping: .clear! is best-effort only
99
+ # - GC copying: Garbage collector may duplicate secrets
100
+ # - String operations: Every manipulation creates copies
101
+ # - Memory persistence: Secrets may remain in memory indefinitely
102
+ #
103
+ # For highly sensitive applications, consider external secrets management
104
+ # (HashiCorp Vault, AWS Secrets Manager) or languages with secure memory handling.
12
105
  #
13
106
  module TransientFields
14
107
  def self.included(base)
15
- Familia.trace :included, base, self, caller(1..1) if Familia.debug?
108
+ Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
16
109
  base.extend ClassMethods
110
+
111
+ # Initialize transient fields tracking
112
+ base.instance_variable_set(:@transient_fields, []) unless base.instance_variable_defined?(:@transient_fields)
17
113
  end
18
114
 
19
- # ClassMethods
20
- #
21
115
  module ClassMethods
22
116
  # Define a transient field that automatically wraps values in RedactedString
23
117
  #
118
+ # Transient fields are not persisted to Redis/Valkey and exist only in memory.
119
+ # All values are automatically wrapped in RedactedString for security.
120
+ #
24
121
  # @param name [Symbol] The field name
25
122
  # @param as [Symbol] The method name (defaults to field name)
26
123
  # @param kwargs [Hash] Additional field options
@@ -31,14 +128,99 @@ module Familia
31
128
  # transient_field :api_key
32
129
  # end
33
130
  #
131
+ # @example Define a transient field with custom accessor name
132
+ # class Service < Familia::Horreum
133
+ # feature :transient_fields
134
+ # transient_field :secret_key, as: :api_secret
135
+ # end
136
+ #
137
+ # service = Service.new(secret_key: 'secret123')
138
+ # service.api_secret.expose { |key| use_api_key(key) }
139
+ #
34
140
  def transient_field(name, as: name, **kwargs)
35
- # Use the field type system - much cleaner than alias_method approach!
36
- # We can now remove the transient_field method from this feature entirely
37
- # since it's built into DefinitionMethods using TransientFieldType
141
+ @transient_fields ||= []
142
+ @transient_fields << name unless @transient_fields.include?(name)
143
+
144
+ # Use the field type system for proper integration
38
145
  require_relative 'transient_fields/transient_field_type'
39
146
  field_type = TransientFieldType.new(name, as: as, **kwargs.merge(fast_method: false))
40
147
  register_field_type(field_type)
41
148
  end
149
+
150
+ # Returns list of transient field names defined on this class
151
+ #
152
+ # @return [Array<Symbol>] Array of transient field names
153
+ #
154
+ def transient_fields
155
+ @transient_fields || []
156
+ end
157
+
158
+ # Check if a field is transient
159
+ #
160
+ # @param field_name [Symbol] The field name to check
161
+ # @return [Boolean] true if field is transient, false otherwise
162
+ #
163
+ def transient_field?(field_name)
164
+ transient_fields.include?(field_name.to_sym)
165
+ end
166
+ end
167
+
168
+ # Clear all transient fields for this instance
169
+ #
170
+ # This method iterates through all defined transient fields and calls
171
+ # clear! on each RedactedString instance. Use this for cleanup when
172
+ # the object is no longer needed.
173
+ #
174
+ # @return [void]
175
+ #
176
+ # @example Clear all secrets when done
177
+ # client = ApiClient.new(token: 'secret', api_key: 'key123')
178
+ # # ... use client ...
179
+ # client.clear_transient_fields!
180
+ # client.token.cleared? # => true
181
+ #
182
+ def clear_transient_fields!
183
+ self.class.transient_fields.each do |field_name|
184
+ field_value = instance_variable_get("@#{field_name}")
185
+ if field_value.respond_to?(:clear!)
186
+ field_value.clear!
187
+ end
188
+ end
189
+ end
190
+
191
+ # Check if all transient fields have been cleared
192
+ #
193
+ # @return [Boolean] true if all transient fields are cleared, false otherwise
194
+ #
195
+ def transient_fields_cleared?
196
+ self.class.transient_fields.all? do |field_name|
197
+ field_value = instance_variable_get("@#{field_name}")
198
+ field_value.nil? || (field_value.respond_to?(:cleared?) && field_value.cleared?)
199
+ end
200
+ end
201
+
202
+ # Returns a hash of transient field names and their redacted representations
203
+ #
204
+ # This method is useful for debugging and logging as it shows which transient
205
+ # fields are defined without exposing their actual values.
206
+ #
207
+ # @return [Hash] Hash with field names as keys and "[REDACTED]" as values
208
+ #
209
+ # @example Check transient field status
210
+ # client.transient_fields_summary
211
+ # # => { token: "[REDACTED]", api_key: "[REDACTED]" }
212
+ #
213
+ def transient_fields_summary
214
+ self.class.transient_fields.each_with_object({}) do |field_name, summary|
215
+ field_value = instance_variable_get("@#{field_name}")
216
+ if field_value.nil?
217
+ summary[field_name] = nil
218
+ elsif field_value.respond_to?(:cleared?) && field_value.cleared?
219
+ summary[field_name] = "[CLEARED]"
220
+ else
221
+ summary[field_name] = "[REDACTED]"
222
+ end
223
+ end
42
224
  end
43
225
 
44
226
  Familia::Base.add_feature self, :transient_fields, depends_on: nil