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
@@ -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
|
-
#
|
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
|
-
#
|
10
|
-
#
|
11
|
-
#
|
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 :
|
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
|
-
|
36
|
-
|
37
|
-
|
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
|