familia 2.0.0.pre5 → 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 (151) 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 -10
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +4 -3
  9. data/docs/wiki/API-Reference.md +95 -18
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +631 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +82 -15
  17. data/docs/wiki/Implementation-Guide.md +126 -33
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/Relationships-Guide.md +684 -0
  20. data/docs/wiki/Security-Model.md +65 -25
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/examples/bit_encoding_integration.rb +237 -0
  23. data/examples/redis_command_validation_example.rb +231 -0
  24. data/examples/relationships_basic.rb +273 -0
  25. data/lib/familia/base.rb +1 -1
  26. data/lib/familia/connection.rb +3 -3
  27. data/lib/familia/data_type/types/counter.rb +38 -0
  28. data/lib/familia/data_type/types/hashkey.rb +18 -0
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/data_type/types/string.rb +9 -2
  31. data/lib/familia/data_type.rb +9 -6
  32. data/lib/familia/encryption/encrypted_data.rb +137 -0
  33. data/lib/familia/encryption/manager.rb +21 -4
  34. data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
  35. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
  36. data/lib/familia/encryption.rb +1 -1
  37. data/lib/familia/errors.rb +17 -3
  38. data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
  39. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
  40. data/lib/familia/features/encrypted_fields.rb +413 -4
  41. data/lib/familia/features/expiration.rb +319 -33
  42. data/lib/familia/features/quantization.rb +385 -44
  43. data/lib/familia/features/relationships/cascading.rb +438 -0
  44. data/lib/familia/features/relationships/indexing.rb +370 -0
  45. data/lib/familia/features/relationships/membership.rb +503 -0
  46. data/lib/familia/features/relationships/permission_management.rb +264 -0
  47. data/lib/familia/features/relationships/querying.rb +620 -0
  48. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  49. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  50. data/lib/familia/features/relationships/tracking.rb +379 -0
  51. data/lib/familia/features/relationships.rb +466 -0
  52. data/lib/familia/features/safe_dump.rb +1 -1
  53. data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
  54. data/lib/familia/features/transient_fields.rb +192 -10
  55. data/lib/familia/features.rb +2 -1
  56. data/lib/familia/field_type.rb +5 -2
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
  58. data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
  63. data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
  64. data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +17 -17
  67. data/lib/familia/validation/command_recorder.rb +336 -0
  68. data/lib/familia/validation/expectations.rb +519 -0
  69. data/lib/familia/validation/test_helpers.rb +443 -0
  70. data/lib/familia/validation/validator.rb +412 -0
  71. data/lib/familia/validation.rb +140 -0
  72. data/lib/familia/version.rb +1 -1
  73. data/lib/familia.rb +1 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -4
  77. data/try/core/familia_try.rb +1 -1
  78. data/try/core/persistence_operations_try.rb +297 -0
  79. data/try/data_types/counter_try.rb +93 -0
  80. data/try/data_types/lock_try.rb +133 -0
  81. data/try/debugging/debug_aad_process.rb +82 -0
  82. data/try/debugging/debug_concealed_internal.rb +59 -0
  83. data/try/debugging/debug_concealed_reveal.rb +61 -0
  84. data/try/debugging/debug_context_aad.rb +68 -0
  85. data/try/debugging/debug_context_simple.rb +80 -0
  86. data/try/debugging/debug_cross_context.rb +62 -0
  87. data/try/debugging/debug_database_load.rb +64 -0
  88. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  89. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  90. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  91. data/try/debugging/debug_field_decrypt.rb +74 -0
  92. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  93. data/try/debugging/debug_load_path.rb +66 -0
  94. data/try/debugging/debug_method_definition.rb +46 -0
  95. data/try/debugging/debug_method_resolution.rb +41 -0
  96. data/try/debugging/debug_minimal.rb +24 -0
  97. data/try/debugging/debug_provider.rb +68 -0
  98. data/try/debugging/debug_secure_behavior.rb +73 -0
  99. data/try/debugging/debug_string_class.rb +46 -0
  100. data/try/debugging/debug_test.rb +46 -0
  101. data/try/debugging/debug_test_design.rb +80 -0
  102. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  103. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  104. data/try/edge_cases/string_coercion_try.rb +2 -0
  105. data/try/encryption/encryption_core_try.rb +6 -4
  106. data/try/features/categorical_permissions_try.rb +515 -0
  107. data/try/features/encrypted_fields_core_try.rb +19 -11
  108. data/try/features/encrypted_fields_integration_try.rb +66 -70
  109. data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
  110. data/try/features/encrypted_fields_security_try.rb +151 -144
  111. data/try/features/encryption_fields/aad_protection_try.rb +108 -23
  112. data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
  113. data/try/features/encryption_fields/context_isolation_try.rb +30 -8
  114. data/try/features/encryption_fields/error_conditions_try.rb +6 -6
  115. data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
  116. data/try/features/encryption_fields/fresh_key_try.rb +27 -22
  117. data/try/features/encryption_fields/key_rotation_try.rb +16 -10
  118. data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
  119. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  120. data/try/features/encryption_fields/thread_safety_try.rb +6 -6
  121. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  122. data/try/features/feature_dependencies_try.rb +3 -3
  123. data/try/features/relationships_edge_cases_try.rb +145 -0
  124. data/try/features/relationships_performance_minimal_try.rb +132 -0
  125. data/try/features/relationships_performance_simple_try.rb +155 -0
  126. data/try/features/relationships_performance_try.rb +420 -0
  127. data/try/features/relationships_performance_working_try.rb +144 -0
  128. data/try/features/relationships_try.rb +237 -0
  129. data/try/features/safe_dump_try.rb +3 -0
  130. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  131. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  132. data/try/features/transient_fields_core_try.rb +1 -1
  133. data/try/features/transient_fields_integration_try.rb +1 -1
  134. data/try/helpers/test_helpers.rb +26 -1
  135. data/try/horreum/base_try.rb +14 -8
  136. data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
  137. data/try/horreum/initialization_try.rb +1 -1
  138. data/try/horreum/relations_try.rb +2 -2
  139. data/try/horreum/serialization_persistent_fields_try.rb +8 -8
  140. data/try/horreum/serialization_try.rb +39 -4
  141. data/try/models/customer_safe_dump_try.rb +1 -1
  142. data/try/models/customer_try.rb +1 -1
  143. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  144. data/try/validation/command_validation_try.rb.disabled +207 -0
  145. data/try/validation/performance_validation_try.rb.disabled +324 -0
  146. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  147. metadata +81 -12
  148. data/TEST_COVERAGE.md +0 -40
  149. data/lib/familia/features/relatable_objects.rb +0 -125
  150. data/lib/familia/horreum/serialization.rb +0 -473
  151. data/try/features/relatable_objects_try.rb +0 -220
@@ -1,29 +1,38 @@
1
- # lib/familia/horreum/definition_methods.rb
1
+ # lib/familia/horreum/subclass/definition.rb
2
2
 
3
3
  require_relative 'related_fields_management'
4
+ require_relative '../shared/settings'
4
5
 
5
6
  module Familia
6
- VALID_STRATEGIES = %i[raise skip warn overwrite].freeze
7
+ VALID_STRATEGIES = %i[raise skip ignore warn overwrite].freeze
7
8
 
8
9
  # Familia::Horreum
9
10
  #
10
11
  class Horreum
11
12
  # Class-level instance variables
12
13
  # These are set up as nil initially and populated later
13
- @dbclient = nil # TODO
14
- @identifier_field = nil
15
- @default_expiration = nil
14
+ #
15
+ # Connection and database settings
16
+ @dbclient = nil
16
17
  @logical_database = nil
17
18
  @uri = nil
18
- @suffix = nil
19
+
20
+ # Database Key generation settings
19
21
  @prefix = nil
20
- @fields = nil # []
21
- @class_related_fields = nil # {}
22
- @related_fields = nil # {}
22
+ @identifier_field = nil
23
+ @suffix = nil
24
+
25
+ # Fields and relationships
26
+ @fields = nil
27
+ @class_related_fields = nil
28
+ @related_fields = nil
29
+ @default_expiration = nil
30
+
31
+ # Serialization settings
23
32
  @dump_method = nil
24
33
  @load_method = nil
25
34
 
26
- # DefinitionMethods: Provides class-level functionality for Horreum
35
+ # DefinitionMethods: Provides class-level functionality for Horreum subclasses
27
36
  #
28
37
  # This module is extended into classes that include Familia::Horreum,
29
38
  # providing methods for Database operations and object management.
@@ -35,7 +44,7 @@ module Familia
35
44
  #
36
45
  module DefinitionMethods
37
46
  include Familia::Settings
38
- include Familia::Horreum::RelatedFieldsManagement
47
+ include Familia::Horreum::RelatedFieldsManagement # Provides DataType field methods
39
48
 
40
49
  # Sets or retrieves the unique identifier field for the class.
41
50
  #
@@ -86,12 +95,12 @@ module Familia
86
95
  # - Others, depending on features available
87
96
  #
88
97
  def field(name, as: name, fast_method: :"#{name}!", on_conflict: :raise, category: nil)
89
- # Use field type system internally for consistency
90
- require_relative '../field_type'
98
+ # Use field type system for consistency
99
+ require_relative '../../field_type'
91
100
 
92
101
  # Create appropriate field type based on category
93
102
  field_type = if category == :transient
94
- require_relative '../features/transient_fields/transient_field_type'
103
+ require_relative '../../features/transient_fields/transient_field_type'
95
104
  TransientFieldType.new(name, as: as, fast_method: false, on_conflict: on_conflict)
96
105
  else
97
106
  # For regular fields and other categories, create custom field type with category override
@@ -160,7 +169,7 @@ module Familia
160
169
  @related_fields
161
170
  end
162
171
 
163
- def has_relations?
172
+ def relations?
164
173
  @has_relations ||= false
165
174
  end
166
175
 
@@ -232,7 +241,7 @@ module Familia
232
241
  # @param options [Hash] Field options
233
242
  #
234
243
  def transient_field(name, **)
235
- require_relative '../features/transient_fields/transient_field_type'
244
+ require_relative '../../features/transient_fields/transient_field_type'
236
245
  field_type = TransientFieldType.new(name, **, fast_method: false)
237
246
  register_field_type(field_type)
238
247
  end
@@ -261,7 +270,7 @@ module Familia
261
270
  WARNING
262
271
  when :raise
263
272
  raise ArgumentError, "Method >>> #{method_name} <<< already defined for #{self}"
264
- when :skip
273
+ when :skip, :ignore
265
274
  # Do nothing, skip silently
266
275
  end
267
276
  end
@@ -279,19 +288,26 @@ module Familia
279
288
  end
280
289
  end
281
290
 
282
- # Defines a fast attribute method with a bang (!) suffix for a given
283
- # attribute name. Fast attribute methods are used to immediately read or
284
- # write attribute values from/to the database. Calling a fast attribute
285
- # method has no effect on any of the object's other attributes and does
286
- # not trigger a call to update the object's expiration time.
291
+ # Fast attribute accessor method for immediate DB persistence.
292
+ #
293
+ # @param field_name [Symbol, String] the name of the horreum model attribute
294
+ # @param method_name [Symbol, String] the name of the regular accessor method
295
+ # @param fast_method_name [Symbol, String] the name of the fast method (must end with '!')
296
+ # @param on_conflict [Symbol] conflict resolution strategy for method name conflicts
287
297
  #
288
- # @param [Symbol, String] name the name of the attribute for which the
289
- # fast method is defined.
290
- # @return [Object] the current value of the attribute when called without
291
- # arguments.
292
- # @raise [ArgumentError] if more than one argument is provided.
293
- # @raise [RuntimeError] if an exception occurs during the execution of the
294
- # method.
298
+ # @return [void]
299
+ #
300
+ # @raise [ArgumentError] if fast_method_name doesn't end with '!'
301
+ #
302
+ # @note Generated method behavior:
303
+ # - Without args: Retrieves current value from Redis
304
+ # - With value: Sets and immediately persists to Redis
305
+ # - Returns boolean indicating success for writes
306
+ # - Bypasses object-level caching and expiration updates
307
+ #
308
+ # @example
309
+ # # Creates a method like: username!(value = nil)
310
+ # define_fast_writer_method(:username, :username, :username!, :raise)
295
311
  #
296
312
  def define_fast_writer_method(field_name, method_name, fast_method_name, on_conflict)
297
313
  raise ArgumentError, 'Must end with !' unless fast_method_name.to_s.end_with?('!')
@@ -1,4 +1,4 @@
1
- # lib/familia/horreum/management_methods.rb
1
+ # lib/familia/horreum/subclass/management.rb
2
2
 
3
3
  require_relative 'related_fields_management'
4
4
 
@@ -15,7 +15,7 @@ module Familia
15
15
  # * Provides utility methods for working with Database objects
16
16
  #
17
17
  module ManagementMethods
18
- include Familia::Horreum::RelatedFieldsManagement
18
+ include Familia::Horreum::RelatedFieldsManagement # Provides DataType query methods
19
19
 
20
20
  # Creates and persists a new instance of the class.
21
21
  #
@@ -55,9 +55,7 @@ module Familia
55
55
  #
56
56
  def create(*, **)
57
57
  fobj = new(*, **)
58
- raise Familia::Problem, "#{self} already exists: #{fobj.dbkey}" if fobj.exists?
59
-
60
- fobj.save
58
+ fobj.save_if_not_exists
61
59
  fobj
62
60
  end
63
61
 
@@ -172,8 +170,8 @@ module Familia
172
170
  # User.exists?(123) # Returns true if user:123:object exists in Redis
173
171
  #
174
172
  def exists?(identifier, suffix = nil)
173
+ raise NoIdentifier, "Empty identifier" if identifier.to_s.empty?
175
174
  suffix ||= self.suffix
176
- return false if identifier.to_s.empty?
177
175
 
178
176
  objkey = dbkey identifier, suffix
179
177
 
@@ -234,9 +232,11 @@ module Familia
234
232
  # distinction b/c passing in an explicitly nil is how DataType objects
235
233
  # at the class level are created without the global default 'object'
236
234
  # suffix. See DataType#dbkey "parent_class?" for more details.
235
+ #
237
236
  def dbkey(identifier, suffix = self.suffix)
238
- # Familia.ld "[.dbkey] #{identifier} for #{self} (suffix:#{suffix})"
239
- raise NoIdentifier, self if identifier.to_s.empty?
237
+ if identifier.to_s.empty?
238
+ raise NoIdentifier, "#{self} requires non-empty identifier, got: #{identifier.inspect}"
239
+ end
240
240
 
241
241
  identifier &&= identifier.to_s
242
242
  Familia.dbkey(prefix, identifier, suffix)
@@ -255,6 +255,7 @@ module Familia
255
255
  # Returns the number of dbkeys matching the given filter pattern
256
256
  # @param filter [String] dbkey pattern to match (default: '*')
257
257
  # @return [Integer] Number of matching keys
258
+ #
258
259
  def matching_keys_count(filter = '*')
259
260
  dbclient.keys(dbkey(filter)).compact.size
260
261
  end
@@ -15,7 +15,7 @@ module Familia
15
15
  #
16
16
  # Usage:
17
17
  # Include this module in classes that need DataType management
18
- # Call setup_relations_accessors to initialize the feature
18
+ # Call setup_related_fields_accessors to initialize the feature
19
19
  #
20
20
  module RelatedFieldsManagement
21
21
  # A practical flag to indicate that a Horreum member has relations,
@@ -23,17 +23,22 @@ module Familia
23
23
  @has_relations = nil
24
24
 
25
25
  def self.included(base)
26
- base.extend(ClassMethods)
27
- base.setup_relations_accessors
26
+ base.extend(RelatedFieldsAccessors)
27
+ base.setup_related_fields_accessors
28
28
  end
29
29
 
30
- module ClassMethods
30
+ module RelatedFieldsAccessors
31
31
  # Sets up all DataType related methods
32
- # This method is the core of the metaprogramming logic
32
+ # This method generates the following for each registered DataType:
33
33
  #
34
- def setup_relations_accessors
34
+ # Instance methods: set(), list(), hashkey(), sorted_set(), etc.
35
+ # Query methods: set?(), list?(), hashkey?(), sorted_set?(), etc.
36
+ # Collection methods: sets(), lists(), hashkeys(), sorted_sets(), etc.
37
+ # Class methods: class_set(), class_list(), etc.
38
+ #
39
+ def setup_related_fields_accessors
35
40
  Familia::DataType.registered_types.each_pair do |kind, klass|
36
- Familia.ld "[registered_types] #{kind} => #{klass}"
41
+ Familia.trace :registered_types, kind, klass, caller(1..1) if Familia.debug?
37
42
 
38
43
  # Dynamically define instance-level relation methods
39
44
  #
@@ -86,11 +91,11 @@ module Familia
86
91
  end
87
92
  end
88
93
  end
89
- # End of ClassMethods module
94
+ # End of RelatedFieldsAccessors module
90
95
 
91
96
  # Creates an instance-level relation
92
97
  def attach_instance_related_field(name, klass, opts)
93
- Familia.ld "[#{self}##{name}] Attaching instance-level #{klass} #{opts}"
98
+ Familia.trace :attach_instance, "#{name} #{klass}", opts, caller(1..1) if Familia.debug?
94
99
  raise ArgumentError, "Name is blank (#{klass})" if name.to_s.empty?
95
100
 
96
101
  name = name.to_s.to_sym
@@ -115,7 +120,7 @@ module Familia
115
120
 
116
121
  # Creates a class-level relation
117
122
  def attach_class_related_field(name, klass, opts)
118
- Familia.ld "[#{self}.#{name}] Attaching class-level #{klass} #{opts}"
123
+ Familia.trace :attach_class_related_field, "#{name} #{klass}", opts, caller(1..1) if Familia.debug?
119
124
  raise ArgumentError, 'Name is blank (klass)' if name.to_s.empty?
120
125
 
121
126
  name = name.to_s.to_sym
@@ -1,5 +1,10 @@
1
1
  # lib/familia/horreum.rb
2
2
 
3
+ require_relative 'horreum/subclass/definition'
4
+ require_relative 'horreum/subclass/management'
5
+ require_relative 'horreum/shared/settings'
6
+ require_relative 'horreum/core'
7
+
3
8
  module Familia
4
9
  #
5
10
  # Horreum: A module for managing Redis-based object storage and relationships
@@ -23,6 +28,8 @@ module Familia
23
28
  #
24
29
  class Horreum
25
30
  include Familia::Base
31
+ include Familia::Horreum::Core
32
+ include Familia::Horreum::Settings
26
33
 
27
34
  # Singleton Class Context
28
35
  #
@@ -62,13 +69,14 @@ module Familia
62
69
  # Extends ClassMethods to subclasses and tracks Familia members
63
70
  def inherited(member)
64
71
  Familia.trace :HORREUM, nil, "Welcome #{member} to the family", caller(1..1) if Familia.debug?
65
- member.extend(DefinitionMethods)
66
- member.extend(ManagementMethods)
67
- member.extend(Connection)
68
- member.extend(Features)
69
72
 
70
- # Tracks all the classes/modules that include Familia. It's
71
- # 10pm, do you know where you Familia members are?
73
+ # Class-level functionality extensions:
74
+ member.extend(Familia::Horreum::DefinitionMethods) # field(), identifier_field(), dbkey()
75
+ member.extend(Familia::Horreum::ManagementMethods) # create(), find(), destroy!()
76
+ member.extend(Familia::Horreum::Connection) # dbclient, connection management
77
+ member.extend(Familia::Features) # feature() method for optional modules
78
+
79
+ # Track all classes that inherit from Horreum
72
80
  Familia.members << member
73
81
  super
74
82
  end
@@ -84,7 +92,7 @@ module Familia
84
92
  # Session.new({sessid: "abc123", custid: "user456"}) # legacy hash (robust)
85
93
  #
86
94
  def initialize(*args, **kwargs)
87
- Familia.ld "[Horreum] Initializing #{self.class}"
95
+ Familia.trace :INITIALIZE, dbclient, "Initializing #{self.class}", caller(1..1) if Familia.debug?
88
96
  initialize_relatives
89
97
 
90
98
  # No longer auto-create a key field - the identifier method will
@@ -123,7 +131,7 @@ module Familia
123
131
  elsif args.any?
124
132
  initialize_with_positional_args(*args)
125
133
  else
126
- Familia.ld "[Horreum] #{self.class} initialized with no arguments"
134
+ Familia.trace :INITIALIZE, dbclient, "#{self.class} initialized with no arguments", caller(1..1) if Familia.debug?
127
135
  # Default values are intentionally NOT set here to:
128
136
  # - Maintain Database memory efficiency (only store non-nil values)
129
137
  # - Avoid conflicts with nil-skipping serialization logic
@@ -158,7 +166,7 @@ module Familia
158
166
  self.class.related_fields.each_pair do |name, data_type_definition|
159
167
  klass = data_type_definition.klass
160
168
  opts = data_type_definition.opts
161
- Familia.ld "[#{self.class}] initialize_relatives #{name} => #{klass} #{opts.keys}"
169
+ Familia.trace :INITIALIZE_RELATIVES, dbclient, "#{name} => #{klass} #{opts.keys}", caller(1..1) if Familia.debug?
162
170
 
163
171
  # As a subclass of Familia::Horreum, we add ourselves as the parent
164
172
  # automatically. This is what determines the dbkey for DataType
@@ -310,11 +318,3 @@ module Familia
310
318
  end
311
319
  end
312
320
  end
313
-
314
- require_relative 'horreum/definition_methods'
315
- require_relative 'horreum/management_methods'
316
- require_relative 'horreum/database_commands'
317
- require_relative 'horreum/connection'
318
- require_relative 'horreum/serialization'
319
- require_relative 'horreum/settings'
320
- require_relative 'horreum/utils'
@@ -0,0 +1,336 @@
1
+ # lib/familia/validation/command_recorder.rb
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module Familia
6
+ module Validation
7
+ # Enhanced command recorder that captures Redis commands with full context
8
+ # for validation purposes. Extends the existing DatabaseLogger functionality
9
+ # to provide detailed command tracking including transaction boundaries.
10
+ #
11
+ # @example Basic usage
12
+ # CommandRecorder.start_recording
13
+ # # ... perform Redis operations
14
+ # commands = CommandRecorder.stop_recording
15
+ # puts commands.map(&:to_s)
16
+ #
17
+ # @example Transaction recording
18
+ # CommandRecorder.start_recording
19
+ # Familia.transaction do |conn|
20
+ # conn.hset("key", "field", "value")
21
+ # conn.incr("counter")
22
+ # end
23
+ # commands = CommandRecorder.stop_recording
24
+ # commands.transaction_blocks.length #=> 1
25
+ # commands.transaction_blocks.first.commands.length #=> 2
26
+ #
27
+ module CommandRecorder
28
+ extend self
29
+
30
+ # Thread-safe recording state
31
+ @recording_state = Concurrent::ThreadLocalVar.new { false }
32
+ @recorded_commands = Concurrent::ThreadLocalVar.new { CommandSequence.new }
33
+ @transaction_stack = Concurrent::ThreadLocalVar.new { [] }
34
+ @pipeline_stack = Concurrent::ThreadLocalVar.new { [] }
35
+
36
+ # Represents a single Redis command with full context
37
+ class RecordedCommand
38
+ attr_reader :command, :args, :result, :timestamp, :duration_us, :context, :command_type
39
+
40
+ def initialize(command:, args:, result:, timestamp:, duration_us:, context: {})
41
+ @command = command.to_s.upcase
42
+ @args = args.dup.freeze
43
+ @result = result
44
+ @timestamp = timestamp
45
+ @duration_us = duration_us
46
+ @context = context.dup.freeze
47
+ @command_type = determine_command_type
48
+ end
49
+
50
+ def to_s
51
+ args_str = @args.map(&:inspect).join(', ')
52
+ "#{@command}(#{args_str})"
53
+ end
54
+
55
+ def to_h
56
+ {
57
+ command: @command,
58
+ args: @args,
59
+ result: @result,
60
+ timestamp: @timestamp,
61
+ duration_us: @duration_us,
62
+ context: @context,
63
+ command_type: @command_type
64
+ }
65
+ end
66
+
67
+ def transaction_command?
68
+ %w[MULTI EXEC DISCARD].include?(@command)
69
+ end
70
+
71
+ def pipeline_command?
72
+ @context[:pipeline] == true
73
+ end
74
+
75
+ def atomic_command?
76
+ @context[:transaction] == true
77
+ end
78
+
79
+ private
80
+
81
+ def determine_command_type
82
+ case @command
83
+ when 'MULTI', 'EXEC', 'DISCARD'
84
+ :transaction_control
85
+ when 'PIPELINE'
86
+ :pipeline_control
87
+ when /^H(GET|SET|DEL|EXISTS|KEYS|LEN|MGET|MSET)/
88
+ :hash
89
+ when /^(L|R)(PUSH|POP|LEN|RANGE|INDEX|SET|REM)/
90
+ :list
91
+ when /^S(ADD|REM|MEMBERS|CARD|ISMEMBER|DIFF|INTER|UNION)/
92
+ :set
93
+ when /^Z(ADD|REM|RANGE|SCORE|CARD|COUNT|RANK|INCR)/
94
+ :sorted_set
95
+ when /^(GET|SET|DEL|EXISTS|EXPIRE|TTL|TYPE|INCR|DECR)/
96
+ :string
97
+ else
98
+ :other
99
+ end
100
+ end
101
+ end
102
+
103
+ # Represents a sequence of Redis commands with transaction boundaries
104
+ class CommandSequence
105
+ attr_reader :commands, :transaction_blocks, :pipeline_blocks
106
+
107
+ def initialize
108
+ @commands = []
109
+ @transaction_blocks = []
110
+ @pipeline_blocks = []
111
+ end
112
+
113
+ def add_command(recorded_command)
114
+ @commands << recorded_command
115
+ end
116
+
117
+ def start_transaction(context = {})
118
+ @transaction_blocks << TransactionBlock.new(context)
119
+ end
120
+
121
+ def end_transaction
122
+ return unless current_transaction
123
+
124
+ current_transaction.finalize(@commands)
125
+ end
126
+
127
+ def start_pipeline(context = {})
128
+ @pipeline_blocks << PipelineBlock.new(context)
129
+ end
130
+
131
+ def end_pipeline
132
+ return unless current_pipeline
133
+
134
+ current_pipeline.finalize(@commands)
135
+ end
136
+
137
+ def current_transaction
138
+ @transaction_blocks.last
139
+ end
140
+
141
+ def current_pipeline
142
+ @pipeline_blocks.last
143
+ end
144
+
145
+ def command_count
146
+ @commands.length
147
+ end
148
+
149
+ def transaction_count
150
+ @transaction_blocks.length
151
+ end
152
+
153
+ def pipeline_count
154
+ @pipeline_blocks.length
155
+ end
156
+
157
+ def to_a
158
+ @commands
159
+ end
160
+
161
+ def clear
162
+ @commands.clear
163
+ @transaction_blocks.clear
164
+ @pipeline_blocks.clear
165
+ end
166
+ end
167
+
168
+ # Represents a transaction block (MULTI/EXEC)
169
+ class TransactionBlock
170
+ attr_reader :start_index, :end_index, :commands, :context, :started_at
171
+
172
+ def initialize(context = {})
173
+ @context = context
174
+ @started_at = Time.now
175
+ @start_index = nil
176
+ @end_index = nil
177
+ @commands = []
178
+ end
179
+
180
+ def finalize(all_commands)
181
+ # Find MULTI and EXEC commands
182
+ multi_index = all_commands.rindex { |cmd| cmd.command == 'MULTI' }
183
+ exec_index = all_commands.rindex { |cmd| cmd.command == 'EXEC' }
184
+
185
+ return unless multi_index && exec_index && exec_index > multi_index
186
+
187
+ @start_index = multi_index
188
+ @end_index = exec_index
189
+ @commands = all_commands[(multi_index + 1)...exec_index]
190
+ end
191
+
192
+ def valid?
193
+ @start_index && @end_index && @commands.any?
194
+ end
195
+
196
+ def command_count
197
+ @commands.length
198
+ end
199
+ end
200
+
201
+ # Represents a pipeline block
202
+ class PipelineBlock
203
+ attr_reader :commands, :context, :started_at
204
+
205
+ def initialize(context = {})
206
+ @context = context
207
+ @started_at = Time.now
208
+ @commands = []
209
+ end
210
+
211
+ def finalize(all_commands)
212
+ # Pipeline commands are those executed within pipeline context
213
+ @commands = all_commands.select(&:pipeline_command?)
214
+ end
215
+
216
+ def command_count
217
+ @commands.length
218
+ end
219
+ end
220
+
221
+ # Start recording Redis commands for the current thread
222
+ def start_recording
223
+ @recording_state.value = true
224
+ @recorded_commands.value = CommandSequence.new
225
+ @transaction_stack.value = []
226
+ @pipeline_stack.value = []
227
+ end
228
+
229
+ # Stop recording and return the recorded command sequence
230
+ def stop_recording
231
+ @recording_state.value = false
232
+ sequence = @recorded_commands.value
233
+ @recorded_commands.value = CommandSequence.new
234
+ sequence
235
+ end
236
+
237
+ # Check if currently recording
238
+ def recording?
239
+ @recording_state.value == true
240
+ end
241
+
242
+ # Record a Redis command with full context
243
+ def record_command(command:, args:, result:, timestamp:, duration_us:, context: {})
244
+ return unless recording?
245
+
246
+ # Enhance context with transaction/pipeline state
247
+ enhanced_context = context.merge(
248
+ transaction: in_transaction?,
249
+ pipeline: in_pipeline?,
250
+ transaction_depth: transaction_depth,
251
+ pipeline_depth: pipeline_depth
252
+ )
253
+
254
+ recorded_cmd = RecordedCommand.new(
255
+ command: command,
256
+ args: args,
257
+ result: result,
258
+ timestamp: timestamp,
259
+ duration_us: duration_us,
260
+ context: enhanced_context
261
+ )
262
+
263
+ sequence = @recorded_commands.value
264
+ sequence.add_command(recorded_cmd)
265
+
266
+ # Handle transaction boundaries
267
+ case recorded_cmd.command
268
+ when 'MULTI'
269
+ sequence.start_transaction(enhanced_context)
270
+ @transaction_stack.value.push(Time.now)
271
+ when 'EXEC', 'DISCARD'
272
+ sequence.end_transaction if sequence.current_transaction
273
+ @transaction_stack.value.pop
274
+ end
275
+ end
276
+
277
+ # Check if we're currently in a transaction
278
+ def in_transaction?
279
+ @transaction_stack.value.any?
280
+ end
281
+
282
+ # Check if we're currently in a pipeline
283
+ def in_pipeline?
284
+ @pipeline_stack.value.any?
285
+ end
286
+
287
+ # Get current transaction nesting depth
288
+ def transaction_depth
289
+ @transaction_stack.value.length
290
+ end
291
+
292
+ # Get current pipeline nesting depth
293
+ def pipeline_depth
294
+ @pipeline_stack.value.length
295
+ end
296
+
297
+ # Get the current command sequence (for inspection during recording)
298
+ def current_sequence
299
+ @recorded_commands.value
300
+ end
301
+
302
+ # Clear all recorded data
303
+ def clear
304
+ @recorded_commands.value.clear
305
+ end
306
+
307
+ # Enhanced middleware that integrates with DatabaseLogger
308
+ module Middleware
309
+ def self.call(command, config)
310
+ return yield unless CommandRecorder.recording?
311
+
312
+ timestamp = Time.now
313
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
314
+
315
+ result = yield
316
+
317
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond) - start_time
318
+
319
+ CommandRecorder.record_command(
320
+ command: command[0],
321
+ args: command[1..-1],
322
+ result: result,
323
+ timestamp: timestamp,
324
+ duration_us: duration,
325
+ context: {
326
+ config: config,
327
+ thread_id: Thread.current.object_id
328
+ }
329
+ )
330
+
331
+ result
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end