familia 2.0.0.pre16 → 2.0.0.pre18

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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -2
  3. data/.github/workflows/{code-smellage.yml → code-smells.yml} +3 -63
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +6 -0
  6. data/CHANGELOG.rst +82 -0
  7. data/CLAUDE.md +47 -2
  8. data/Gemfile.lock +1 -1
  9. data/README.md +13 -0
  10. data/bin/irb +1 -1
  11. data/docs/archive/FAMILIA_TECHNICAL.md +1 -1
  12. data/docs/guides/core-field-system.md +48 -26
  13. data/docs/migrating/v2.0.0-pre18.md +58 -0
  14. data/docs/overview.md +2 -2
  15. data/docs/qodo-merge-compliance.md +96 -0
  16. data/docs/reference/api-technical.md +1 -1
  17. data/examples/encrypted_fields.rb +1 -1
  18. data/examples/safe_dump.rb +1 -1
  19. data/lib/familia/base.rb +6 -6
  20. data/lib/familia/connection/middleware.rb +58 -4
  21. data/lib/familia/connection.rb +1 -1
  22. data/lib/familia/data_type/class_methods.rb +63 -0
  23. data/lib/familia/data_type/connection.rb +83 -0
  24. data/lib/familia/data_type/{commands.rb → database_commands.rb} +2 -2
  25. data/lib/familia/data_type/serialization.rb +5 -5
  26. data/lib/familia/data_type/settings.rb +96 -0
  27. data/lib/familia/data_type/types/hashkey.rb +2 -1
  28. data/lib/familia/data_type/types/sorted_set.rb +113 -10
  29. data/lib/familia/data_type/types/stringkey.rb +0 -4
  30. data/lib/familia/data_type.rb +8 -195
  31. data/lib/familia/encryption/encrypted_data.rb +12 -2
  32. data/lib/familia/encryption/manager.rb +11 -4
  33. data/lib/familia/features/autoloader.rb +3 -1
  34. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +11 -3
  35. data/lib/familia/features/encrypted_fields.rb +5 -2
  36. data/lib/familia/features/external_identifier.rb +49 -8
  37. data/lib/familia/features/object_identifier.rb +84 -12
  38. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +9 -9
  39. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +45 -26
  40. data/lib/familia/features/relationships/indexing.rb +7 -1
  41. data/lib/familia/features/relationships/participation/participant_methods.rb +6 -2
  42. data/lib/familia/features/safe_dump.rb +2 -3
  43. data/lib/familia/features/transient_fields.rb +7 -2
  44. data/lib/familia/features.rb +6 -1
  45. data/lib/familia/field_type.rb +0 -18
  46. data/lib/familia/horreum/{core/connection.rb → connection.rb} +21 -0
  47. data/lib/familia/horreum/{core/database_commands.rb → database_commands.rb} +1 -1
  48. data/lib/familia/horreum/{subclass/definition.rb → definition.rb} +102 -56
  49. data/lib/familia/horreum/{subclass/management.rb → management.rb} +18 -15
  50. data/lib/familia/horreum/{core/serialization.rb → persistence.rb} +73 -170
  51. data/lib/familia/horreum/{subclass/related_fields_management.rb → related_fields.rb} +22 -2
  52. data/lib/familia/horreum/serialization.rb +190 -0
  53. data/lib/familia/horreum.rb +39 -14
  54. data/lib/familia/identifier_extractor.rb +60 -0
  55. data/lib/familia/logging.rb +271 -112
  56. data/lib/familia/refinements.rb +0 -1
  57. data/lib/familia/version.rb +1 -1
  58. data/lib/familia.rb +2 -2
  59. data/lib/middleware/{database_middleware.rb → database_logger.rb} +47 -14
  60. data/pr_agent.toml +31 -0
  61. data/pr_compliance_checklist.yaml +45 -0
  62. data/try/edge_cases/empty_identifiers_try.rb +1 -1
  63. data/try/edge_cases/hash_symbolization_try.rb +31 -31
  64. data/try/edge_cases/json_serialization_try.rb +2 -2
  65. data/try/edge_cases/legacy_data_detection/deserialization_edge_cases_try.rb +170 -0
  66. data/try/edge_cases/race_conditions_try.rb +1 -1
  67. data/try/edge_cases/reserved_keywords_try.rb +1 -1
  68. data/try/edge_cases/string_coercion_try.rb +1 -1
  69. data/try/edge_cases/ttl_side_effects_try.rb +1 -1
  70. data/try/features/encrypted_fields/aad_protection_try.rb +1 -1
  71. data/try/features/encrypted_fields/concealed_string_core_try.rb +1 -1
  72. data/try/features/encrypted_fields/context_isolation_try.rb +1 -1
  73. data/try/features/encrypted_fields/encrypted_fields_core_try.rb +1 -1
  74. data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +1 -1
  75. data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +1 -1
  76. data/try/features/encrypted_fields/encrypted_fields_security_try.rb +1 -1
  77. data/try/features/encrypted_fields/error_conditions_try.rb +1 -1
  78. data/try/features/encrypted_fields/fresh_key_derivation_try.rb +1 -1
  79. data/try/features/encrypted_fields/fresh_key_try.rb +1 -1
  80. data/try/features/encrypted_fields/key_rotation_try.rb +1 -1
  81. data/try/features/encrypted_fields/memory_security_try.rb +1 -1
  82. data/try/features/encrypted_fields/missing_current_key_version_try.rb +1 -1
  83. data/try/features/encrypted_fields/nonce_uniqueness_try.rb +1 -1
  84. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +1 -1
  85. data/try/features/encrypted_fields/thread_safety_try.rb +1 -1
  86. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +1 -1
  87. data/try/{encryption → features/encryption}/config_persistence_try.rb +1 -1
  88. data/try/{encryption/encryption_core_try.rb → features/encryption/core_try.rb} +2 -2
  89. data/try/{encryption → features/encryption}/instance_variable_scope_try.rb +1 -1
  90. data/try/{encryption → features/encryption}/module_loading_try.rb +1 -1
  91. data/try/{encryption → features/encryption}/providers/aes_gcm_provider_try.rb +1 -1
  92. data/try/{encryption → features/encryption}/providers/xchacha20_poly1305_provider_try.rb +1 -1
  93. data/try/{encryption → features/encryption}/roundtrip_validation_try.rb +1 -1
  94. data/try/{encryption → features/encryption}/secure_memory_handling_try.rb +2 -2
  95. data/try/features/expiration/expiration_try.rb +1 -1
  96. data/try/features/external_identifier/external_identifier_try.rb +1 -1
  97. data/try/features/feature_dependencies_try.rb +1 -1
  98. data/try/features/feature_improvements_try.rb +1 -1
  99. data/try/features/field_groups_try.rb +244 -0
  100. data/try/features/object_identifier/object_identifier_integration_try.rb +1 -1
  101. data/try/features/object_identifier/object_identifier_try.rb +1 -1
  102. data/try/features/quantization/quantization_try.rb +1 -1
  103. data/try/features/real_feature_integration_try.rb +17 -14
  104. data/try/features/relationships/indexing_commands_verification_try.rb +8 -3
  105. data/try/features/relationships/indexing_try.rb +16 -1
  106. data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
  107. data/try/features/relationships/participation_commands_verification_try.rb +4 -4
  108. data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
  109. data/try/features/relationships/participation_reverse_index_try.rb +1 -1
  110. data/try/features/relationships/relationships_api_changes_try.rb +1 -1
  111. data/try/features/relationships/relationships_edge_cases_try.rb +3 -3
  112. data/try/features/relationships/relationships_performance_minimal_try.rb +1 -1
  113. data/try/features/relationships/relationships_performance_simple_try.rb +1 -1
  114. data/try/features/relationships/relationships_performance_try.rb +1 -1
  115. data/try/features/relationships/relationships_performance_working_try.rb +1 -1
  116. data/try/features/relationships/relationships_try.rb +1 -1
  117. data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
  118. data/try/features/safe_dump/safe_dump_try.rb +1 -1
  119. data/try/features/transient_fields/redacted_string_try.rb +1 -1
  120. data/try/features/transient_fields/refresh_reset_try.rb +3 -1
  121. data/try/features/transient_fields/single_use_redacted_string_try.rb +1 -1
  122. data/try/features/transient_fields/transient_fields_core_try.rb +1 -1
  123. data/try/features/transient_fields/transient_fields_integration_try.rb +1 -1
  124. data/try/{connection → integration/connection}/fiber_context_preservation_try.rb +1 -1
  125. data/try/{connection → integration/connection}/handler_constraints_try.rb +1 -1
  126. data/try/{core → integration/connection}/isolated_dbclient_try.rb +3 -3
  127. data/try/integration/connection/middleware_reconnect_try.rb +87 -0
  128. data/try/{connection → integration/connection}/operation_mode_guards_try.rb +1 -1
  129. data/try/{connection → integration/connection}/pipeline_fallback_integration_try.rb +1 -1
  130. data/try/{core → integration/connection}/pools_try.rb +1 -1
  131. data/try/{connection → integration/connection}/responsibility_chain_tracking_try.rb +1 -1
  132. data/try/{connection → integration/connection}/transaction_fallback_integration_try.rb +1 -1
  133. data/try/{connection → integration/connection}/transaction_mode_permissive_try.rb +1 -1
  134. data/try/{connection → integration/connection}/transaction_mode_strict_try.rb +1 -1
  135. data/try/{connection → integration/connection}/transaction_mode_warn_try.rb +1 -1
  136. data/try/{connection → integration/connection}/transaction_modes_try.rb +1 -1
  137. data/try/{core → integration}/conventional_inheritance_try.rb +1 -1
  138. data/try/{core → integration}/create_method_try.rb +1 -1
  139. data/try/integration/cross_component_try.rb +1 -1
  140. data/try/{core → integration}/database_consistency_try.rb +12 -8
  141. data/try/{core → integration}/familia_extended_try.rb +1 -1
  142. data/try/{core → integration}/familia_members_methods_try.rb +1 -1
  143. data/try/{models → integration/models}/customer_safe_dump_try.rb +1 -1
  144. data/try/{models → integration/models}/customer_try.rb +6 -6
  145. data/try/{models → integration/models}/datatype_base_try.rb +1 -1
  146. data/try/{models → integration/models}/familia_object_try.rb +1 -1
  147. data/try/{core → integration}/persistence_operations_try.rb +1 -1
  148. data/try/integration/relationships_persistence_round_trip_try.rb +441 -0
  149. data/try/{configuration → integration}/scenarios_try.rb +2 -2
  150. data/try/{core → integration}/secure_identifier_try.rb +1 -1
  151. data/try/{core → integration}/verifiable_identifier_try.rb +1 -1
  152. data/try/performance/benchmarks_try.rb +2 -2
  153. data/try/support/benchmarks/deserialization_benchmark.rb +180 -0
  154. data/try/support/benchmarks/deserialization_correctness_test.rb +237 -0
  155. data/try/{helpers → support/helpers}/test_helpers.rb +15 -7
  156. data/try/{memory → support/memory}/memory_docker_ruby_dump.sh +1 -1
  157. data/try/{core → unit/core}/autoloader_try.rb +1 -1
  158. data/try/{core → unit/core}/base_enhancements_try.rb +1 -9
  159. data/try/{core → unit/core}/connection_try.rb +5 -5
  160. data/try/{core → unit/core}/errors_try.rb +4 -4
  161. data/try/{core → unit/core}/extensions_try.rb +1 -1
  162. data/try/unit/core/familia_logger_try.rb +110 -0
  163. data/try/{core → unit/core}/familia_try.rb +2 -2
  164. data/try/{core → unit/core}/middleware_try.rb +41 -1
  165. data/try/{core → unit/core}/settings_try.rb +1 -1
  166. data/try/{core → unit/core}/time_utils_try.rb +1 -1
  167. data/try/{core → unit/core}/tools_try.rb +3 -3
  168. data/try/{core → unit/core}/utils_try.rb +17 -14
  169. data/try/{data_types → unit/data_types}/boolean_try.rb +1 -1
  170. data/try/{data_types → unit/data_types}/counter_try.rb +1 -1
  171. data/try/{data_types → unit/data_types}/datatype_base_try.rb +1 -1
  172. data/try/{data_types → unit/data_types}/hash_try.rb +1 -1
  173. data/try/{data_types → unit/data_types}/list_try.rb +1 -1
  174. data/try/{data_types → unit/data_types}/lock_try.rb +1 -1
  175. data/try/{data_types → unit/data_types}/sorted_set_try.rb +1 -1
  176. data/try/unit/data_types/sorted_set_zadd_options_try.rb +625 -0
  177. data/try/{data_types → unit/data_types}/string_try.rb +1 -1
  178. data/try/{data_types → unit/data_types}/unsortedset_try.rb +1 -1
  179. data/try/unit/horreum/auto_indexing_on_save_try.rb +212 -0
  180. data/try/{horreum → unit/horreum}/base_try.rb +3 -3
  181. data/try/{horreum → unit/horreum}/class_methods_try.rb +1 -1
  182. data/try/{horreum → unit/horreum}/commands_try.rb +3 -1
  183. data/try/unit/horreum/defensive_initialization_try.rb +86 -0
  184. data/try/{horreum → unit/horreum}/destroy_related_fields_cleanup_try.rb +3 -1
  185. data/try/{horreum → unit/horreum}/enhanced_conflict_handling_try.rb +1 -1
  186. data/try/{horreum → unit/horreum}/field_categories_try.rb +27 -18
  187. data/try/{horreum → unit/horreum}/field_definition_try.rb +1 -1
  188. data/try/{horreum → unit/horreum}/initialization_try.rb +2 -2
  189. data/try/unit/horreum/json_type_preservation_try.rb +248 -0
  190. data/try/{horreum → unit/horreum}/relations_try.rb +1 -1
  191. data/try/{horreum → unit/horreum}/serialization_persistent_fields_try.rb +24 -18
  192. data/try/{horreum → unit/horreum}/serialization_try.rb +4 -4
  193. data/try/{horreum → unit/horreum}/settings_try.rb +3 -1
  194. data/try/{refinements → unit/refinements}/dear_json_array_methods_try.rb +1 -1
  195. data/try/{refinements → unit/refinements}/dear_json_hash_methods_try.rb +1 -1
  196. data/try/{refinements → unit/refinements}/time_literals_numeric_methods_try.rb +1 -1
  197. data/try/{refinements → unit/refinements}/time_literals_string_methods_try.rb +1 -1
  198. data/try/valkey.conf +26 -0
  199. metadata +149 -132
  200. data/lib/familia/distinguisher.rb +0 -85
  201. data/lib/familia/horreum/core.rb +0 -21
  202. data/lib/familia/refinements/logger_trace.rb +0 -60
  203. data/try/refinements/logger_trace_methods_try.rb +0 -44
  204. /data/lib/familia/horreum/{shared/settings.rb → settings.rb} +0 -0
  205. /data/lib/familia/horreum/{core/utils.rb → utils.rb} +0 -0
  206. /data/try/{debugging → support/debugging}/README.md +0 -0
  207. /data/try/{debugging → support/debugging}/cache_behavior_tracer.rb +0 -0
  208. /data/try/{debugging → support/debugging}/debug_aad_process.rb +0 -0
  209. /data/try/{debugging → support/debugging}/debug_concealed_internal.rb +0 -0
  210. /data/try/{debugging → support/debugging}/debug_concealed_reveal.rb +0 -0
  211. /data/try/{debugging → support/debugging}/debug_context_aad.rb +0 -0
  212. /data/try/{debugging → support/debugging}/debug_context_simple.rb +0 -0
  213. /data/try/{debugging → support/debugging}/debug_cross_context.rb +0 -0
  214. /data/try/{debugging → support/debugging}/debug_database_load.rb +0 -0
  215. /data/try/{debugging → support/debugging}/debug_encrypted_json_check.rb +0 -0
  216. /data/try/{debugging → support/debugging}/debug_encrypted_json_step_by_step.rb +0 -0
  217. /data/try/{debugging → support/debugging}/debug_exists_lifecycle.rb +0 -0
  218. /data/try/{debugging → support/debugging}/debug_field_decrypt.rb +0 -0
  219. /data/try/{debugging → support/debugging}/debug_fresh_cross_context.rb +0 -0
  220. /data/try/{debugging → support/debugging}/debug_load_path.rb +0 -0
  221. /data/try/{debugging → support/debugging}/debug_method_definition.rb +0 -0
  222. /data/try/{debugging → support/debugging}/debug_method_resolution.rb +0 -0
  223. /data/try/{debugging → support/debugging}/debug_minimal.rb +0 -0
  224. /data/try/{debugging → support/debugging}/debug_provider.rb +0 -0
  225. /data/try/{debugging → support/debugging}/debug_secure_behavior.rb +0 -0
  226. /data/try/{debugging → support/debugging}/debug_string_class.rb +0 -0
  227. /data/try/{debugging → support/debugging}/debug_test.rb +0 -0
  228. /data/try/{debugging → support/debugging}/debug_test_design.rb +0 -0
  229. /data/try/{debugging → support/debugging}/encryption_method_tracer.rb +0 -0
  230. /data/try/{debugging → support/debugging}/provider_diagnostics.rb +0 -0
  231. /data/try/{helpers → support/helpers}/test_cleanup.rb +0 -0
  232. /data/try/{memory → support/memory}/memory_basic_test.rb +0 -0
  233. /data/try/{memory → support/memory}/memory_detailed_test.rb +0 -0
  234. /data/try/{memory → support/memory}/memory_search_for_string.rb +0 -0
  235. /data/try/{memory → support/memory}/test_actual_redactedstring_protection.rb +0 -0
  236. /data/try/{prototypes → support/prototypes}/atomic_saves_v1_context_proxy.rb +0 -0
  237. /data/try/{prototypes → support/prototypes}/atomic_saves_v2_connection_switching.rb +0 -0
  238. /data/try/{prototypes → support/prototypes}/atomic_saves_v3_connection_pool.rb +0 -0
  239. /data/try/{prototypes → support/prototypes}/atomic_saves_v4.rb +0 -0
  240. /data/try/{prototypes → support/prototypes}/lib/atomic_saves_v2_connection_switching_helpers.rb +0 -0
  241. /data/try/{prototypes → support/prototypes}/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
  242. /data/try/{prototypes → support/prototypes}/pooling/README.md +0 -0
  243. /data/try/{prototypes → support/prototypes}/pooling/configurable_stress_test.rb +0 -0
  244. /data/try/{prototypes → support/prototypes}/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
  245. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_metrics.rb +0 -0
  246. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_stress_test.rb +0 -0
  247. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_threading_models.rb +0 -0
  248. /data/try/{prototypes → support/prototypes}/pooling/lib/visualize_stress_results.rb +0 -0
  249. /data/try/{prototypes → support/prototypes}/pooling/pool_siege.rb +0 -0
  250. /data/try/{prototypes → support/prototypes}/pooling/run_stress_tests.rb +0 -0
@@ -1,4 +1,4 @@
1
- # lib/familia/horreum/serialization.rb
1
+ # lib/familia/horreum/persistence.rb
2
2
 
3
3
  module Familia
4
4
  # Familia::Horreum
@@ -34,7 +34,7 @@ module Familia
34
34
  # Serialization - Instance-level methods for object persistence and retrieval
35
35
  # Handles conversion between Ruby objects and Valkey hash storage
36
36
  #
37
- module Serialization
37
+ module Persistence
38
38
  # Persists the object to Valkey storage with automatic timestamping.
39
39
  #
40
40
  # Saves the current object state to Valkey storage, automatically setting
@@ -68,11 +68,18 @@ module Familia
68
68
  self.updated = Familia.now.to_i if respond_to?(:updated)
69
69
 
70
70
  # Commit our tale to the Database chronicles
71
- #
71
+ # Wrap in transaction for atomicity between save and indexing
72
72
  ret = commit_fields(update_expiration: update_expiration)
73
73
 
74
- # Add to class-level instances collection after successful save
75
- self.class.instances.add(identifier, Familia.now) if ret && self.class.respond_to?(:instances)
74
+ # Auto-index for class-level indexes after successful save
75
+ # Use transaction to ensure atomicity with the save operation
76
+ if ret
77
+ transaction do |conn|
78
+ auto_update_class_indexes
79
+ # Add to class-level instances collection after successful save
80
+ self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
81
+ end
82
+ end
76
83
 
77
84
  Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"
78
85
 
@@ -128,7 +135,7 @@ module Familia
128
135
  Familia.ld "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
129
136
  Familia.trace :SAVE_IF_NOT_EXISTS, nil, uri if Familia.debug?
130
137
 
131
- dbclient.watch(dbkey) do
138
+ success = dbclient.watch(dbkey) do
132
139
  if dbclient.exists(dbkey).positive?
133
140
  dbclient.unwatch
134
141
  raise Familia::RecordExistsError, dbkey
@@ -140,6 +147,16 @@ module Familia
140
147
 
141
148
  result.is_a?(Array) # transaction succeeded
142
149
  end
150
+
151
+ # Auto-index for class-level indexes after successful save
152
+ # Use transaction to ensure atomicity with the save operation
153
+ if success
154
+ transaction do |conn|
155
+ auto_update_class_indexes
156
+ end
157
+ end
158
+
159
+ success
143
160
  end
144
161
 
145
162
  # Commits object fields to the DB storage.
@@ -337,7 +354,7 @@ module Familia
337
354
  # their uninitialized state during refresh operations
338
355
  reset_transient_fields!
339
356
 
340
- optimistic_refresh(**fields)
357
+ naive_refresh(**fields)
341
358
  end
342
359
 
343
360
  # Refreshes object state from the DB and returns self for method chaining.
@@ -361,169 +378,6 @@ module Familia
361
378
  self
362
379
  end
363
380
 
364
- # Converts the object's persistent fields to a hash for external use.
365
- #
366
- # Serializes persistent field values for external consumption (APIs, logs),
367
- # excluding non-loggable fields like encrypted fields for security.
368
- # Only non-nil values are included in the resulting hash.
369
- #
370
- # @return [Hash] Hash with field names as keys and serialized values
371
- # safe for external exposure
372
- #
373
- # @example Converting an object to hash format for API response
374
- # user = User.new(name: "John", email: "john@example.com", age: 30)
375
- # user.to_h
376
- # # => {"name"=>"John", "email"=>"john@example.com", "age"=>"30"}
377
- # # encrypted fields are excluded for security
378
- #
379
- # @note Only loggable fields are included for security
380
- # @note Only fields with non-nil values are included
381
- #
382
- def to_h
383
- self.class.persistent_fields.each_with_object({}) do |field, hsh|
384
- field_type = self.class.field_types[field]
385
-
386
- # Security: Skip non-loggable fields (e.g., encrypted fields)
387
- next unless field_type.loggable
388
-
389
- method_name = field_type.method_name
390
- val = send(method_name)
391
- prepared = serialize_value(val)
392
- Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
393
-
394
- # Only include non-nil values in the hash for Valkey
395
- # Use string key for database compatibility
396
- hsh[field.to_s] = prepared unless prepared.nil?
397
- end
398
- end
399
-
400
- # Converts the object's persistent fields to a hash for database storage.
401
- #
402
- # Serializes ALL persistent field values for database storage, including
403
- # encrypted fields. This is used internally by commit_fields and other
404
- # persistence operations.
405
- #
406
- # @return [Hash] Hash with field names as keys and serialized values
407
- # ready for database storage
408
- #
409
- # @note Includes ALL persistent fields, including encrypted fields
410
- # @note Only fields with non-nil values are included for storage efficiency
411
- #
412
- def to_h_for_storage
413
- self.class.persistent_fields.each_with_object({}) do |field, hsh|
414
- field_type = self.class.field_types[field]
415
- method_name = field_type.method_name
416
- val = send(method_name)
417
- prepared = serialize_value(val)
418
- Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
419
-
420
- # Only include non-nil values in the hash for Valkey
421
- # Use string key for database compatibility
422
- hsh[field.to_s] = prepared unless prepared.nil?
423
- end
424
- end
425
-
426
- # Converts the object's persistent fields to an array.
427
- #
428
- # Serializes all persistent field values in field definition order,
429
- # preparing them for Valkey storage. Each value is processed through
430
- # the serialization pipeline to ensure Valkey compatibility.
431
- #
432
- # @return [Array] Array of serialized field values in field order
433
- #
434
- # @example Converting an object to array format
435
- # user = User.new(name: "John", email: "john@example.com", age: 30)
436
- # user.to_a
437
- # # => ["John", "john@example.com", "30"]
438
- #
439
- # @note Values are serialized using the same process as other persistence
440
- # methods to maintain data consistency across operations.
441
- #
442
- def to_a
443
- self.class.persistent_fields.filter_map do |field|
444
- field_type = self.class.field_types[field]
445
-
446
- # Security: Skip non-loggable fields (e.g., encrypted fields)
447
- next unless field_type.loggable
448
-
449
- method_name = field_type.method_name
450
- val = send(method_name)
451
- prepared = serialize_value(val)
452
- Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class} prepared: #{prepared.class}"
453
- prepared
454
- end
455
- end
456
-
457
- # Serializes a Ruby object for Valkey storage.
458
- #
459
- # Converts Ruby objects into the DB-compatible string representations using
460
- # the Familia distinguisher for type coercion. Falls back to JSON serialization
461
- # for complex types (Hash, Array) when the primary distinguisher returns nil.
462
- #
463
- # The serialization process:
464
- # 1. Attempts conversion using Familia.distinguisher with relaxed type checking
465
- # 2. For Hash/Array types that return nil, tries custom dump_method or Familia::JsonSerializer.dump
466
- # 3. Logs warnings when serialization fails completely
467
- #
468
- # @param val [Object] The Ruby object to serialize for Valkey storage
469
- #
470
- # @return [String, nil] The serialized value ready for Valkey storage, or nil
471
- # if serialization failed
472
- #
473
- # @example Serializing different data types
474
- # serialize_value("hello") # => "hello"
475
- # serialize_value(42) # => "42"
476
- # serialize_value({name: "John"}) # => '{"name":"John"}'
477
- # serialize_value([1, 2, 3]) # => "[1,2,3]"
478
- #
479
- # @note This method integrates with Familia's type system and supports
480
- # custom serialization methods when available on the object
481
- #
482
- # @see Familia.distinguisher The primary serialization mechanism
483
- #
484
- def serialize_value(val)
485
- # Security: Handle ConcealedString safely - extract encrypted data for storage
486
- return val.encrypted_value if val.respond_to?(:encrypted_value)
487
-
488
- prepared = Familia.distinguisher(val, strict_values: false)
489
-
490
- # If the distinguisher returns nil, try using the dump_method but only
491
- # use JSON serialization for complex types that need it.
492
- if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
493
- prepared = val.respond_to?(dump_method) ? val.send(dump_method) : Familia::JsonSerializer.dump(val)
494
- end
495
-
496
- # If both the distinguisher and dump_method return nil, log an error
497
- Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}" if prepared.nil?
498
-
499
- prepared
500
- end
501
-
502
- # Converts a Database string value back to its original Ruby type
503
- #
504
- # This method attempts to deserialize JSON strings back to their original
505
- # Hash or Array types. Simple string values are returned as-is.
506
- #
507
- # @param val [String] The string value from Database to deserialize
508
- # @param symbolize [Boolean] Whether to symbolize hash keys (default: true for compatibility)
509
- # @return [Object] The deserialized value (Hash, Array, or original string)
510
- #
511
- def deserialize_value(val, symbolize: true)
512
- return val if val.nil? || val == ''
513
-
514
- # Try to parse as JSON first for complex types
515
- begin
516
- parsed = Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
517
- # Only return parsed value if it's a complex type (Hash/Array)
518
- # Simple values should remain as strings
519
- return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
520
- rescue Familia::SerializerError
521
- # Not valid JSON, return as-is
522
- end
523
-
524
- val
525
- end
526
-
527
381
  private
528
382
 
529
383
  # Reset all transient fields to nil
@@ -547,6 +401,55 @@ module Familia
547
401
  Familia.ld "[reset_transient_fields!] Reset #{field_name} to nil"
548
402
  end
549
403
  end
404
+
405
+ # Automatically update class-level indexes after save
406
+ #
407
+ # Iterates through class-level indexing relationships and calls their
408
+ # corresponding add_to_class_* methods to populate indexes. Only processes
409
+ # class-level indexes (where target_class == self.class), skipping
410
+ # instance-scoped indexes which require parent context.
411
+ #
412
+ # Uses idempotent Redis commands (HSET for unique_index) so repeated calls
413
+ # are safe and have negligible performance overhead. Note that multi_index
414
+ # always requires within: parameter, so only unique_index benefits from this.
415
+ #
416
+ # @return [void]
417
+ #
418
+ # @example Automatic indexing on save
419
+ # class Customer < Familia::Horreum
420
+ # feature :relationships
421
+ # unique_index :email, :email_lookup
422
+ # end
423
+ #
424
+ # customer = Customer.new(email: 'test@example.com')
425
+ # customer.save # Automatically calls add_to_class_email_lookup
426
+ #
427
+ # @note Only class-level unique_index declarations auto-populate.
428
+ # Instance-scoped indexes (with within:) require manual population:
429
+ # employee.add_to_company_badge_index(company)
430
+ #
431
+ # @see Familia::Features::Relationships::Indexing For index declaration details
432
+ #
433
+ def auto_update_class_indexes
434
+ return unless self.class.respond_to?(:indexing_relationships)
435
+
436
+ self.class.indexing_relationships.each do |rel|
437
+ # Skip instance-scoped indexes (require parent context)
438
+ # Instance-scoped indexes must be manually populated because they need
439
+ # the parent object reference (e.g., employee.add_to_company_badge_index(company))
440
+ unless rel.target_class == self.class
441
+ Familia.ld <<~LOG_MESSAGE
442
+ [auto_update_class_indexes] Skipping #{rel.index_name} (requires parent context)
443
+ LOG_MESSAGE
444
+ next
445
+ end
446
+
447
+ # Call the existing add_to_class_* methods
448
+ add_method = :"add_to_class_#{rel.index_name}"
449
+ send(add_method) if respond_to?(add_method)
450
+ end
451
+ end
452
+
550
453
  end
551
454
  end
552
455
  end
@@ -1,4 +1,4 @@
1
- # lib/familia/horreum/related_fields_management.rb
1
+ # lib/familia/horreum/related_fields.rb
2
2
 
3
3
  module Familia
4
4
 
@@ -170,7 +170,27 @@ module Familia
170
170
 
171
171
  related_fields[name] = RelatedFieldDefinition.new(name, klass, opts)
172
172
 
173
- attr_reader name
173
+ # Create lazy-initializing accessor that calls initialize_relatives if needed
174
+ define_method name do
175
+ ivar = :"@#{name}"
176
+ value = instance_variable_get(ivar)
177
+
178
+ # If nil and we haven't initialized relatives, do it now
179
+ # Check singleton class to avoid polluting instance variables
180
+ if value.nil? && !singleton_class.instance_variable_defined?(:"@relatives_initialized")
181
+ initialize_relatives
182
+ value = instance_variable_get(ivar)
183
+ end
184
+
185
+ # If still nil after lazy initialization attempt, raise helpful error
186
+ # Only raise if we tried to initialize but it's still nil
187
+ if value.nil? && singleton_class.instance_variable_defined?(:"@relatives_initialized")
188
+ raise "#{self.class}##{name} is nil. Did you override initialize without calling super? " \
189
+ "(Field is nil after initialization attempt)"
190
+ end
191
+
192
+ value
193
+ end
174
194
 
175
195
  define_method :"#{name}=" do |val|
176
196
  send(name).replace val
@@ -0,0 +1,190 @@
1
+ # lib/familia/horreum/serialization.rb
2
+
3
+ module Familia
4
+ class Horreum
5
+ # Serialization - Instance-level methods for object serialization
6
+ # Handles conversion between Ruby objects and Valkey hash storage
7
+ module Serialization
8
+ # Converts the object's persistent fields to a hash for external use.
9
+ #
10
+ # Returns actual Ruby values (String, Integer, Hash, etc.) for API consumption,
11
+ # NOT JSON-encoded strings. Excludes non-loggable fields like encrypted fields
12
+ # for security.
13
+ #
14
+ # @return [Hash] Hash with field names as string keys and Ruby values
15
+ #
16
+ # @example Converting an object to hash format for API response
17
+ # user = User.new(name: "John", email: "john@example.com", age: 30)
18
+ # user.to_h
19
+ # # => {"name"=>"John", "email"=>"john@example.com", "age"=>30}
20
+ # # Note: Returns actual Ruby types, not JSON strings
21
+ #
22
+ # @note Only loggable fields are included. Encrypted fields are excluded.
23
+ # @note Nil values are excluded from the returned hash (storage optimization)
24
+ #
25
+ def to_h
26
+ self.class.persistent_fields.each_with_object({}) do |field, hsh|
27
+ field_type = self.class.field_types[field]
28
+
29
+ # Security: Skip non-loggable fields (e.g., encrypted fields)
30
+ next unless field_type.loggable
31
+
32
+ val = send(field_type.method_name)
33
+ Familia.ld " [to_h] field: #{field} val: #{val.class}"
34
+
35
+ # Use string key for external API compatibility
36
+ # Return Ruby values, not JSON-encoded strings
37
+ hsh[field.to_s] = val
38
+ end
39
+ end
40
+
41
+ # Converts the object's persistent fields to a hash for database storage.
42
+ #
43
+ # Returns JSON-encoded strings for ALL persistent field values, ready for
44
+ # Redis storage. Unlike to_h, this includes encrypted fields and serializes
45
+ # values using serialize_value (JSON encoding).
46
+ #
47
+ # @return [Hash] Hash with field names as string keys and JSON-encoded values
48
+ #
49
+ # @example Internal storage preparation
50
+ # user = User.new(name: "John", age: 30)
51
+ # user.to_h_for_storage
52
+ # # => {"name"=>"\"John\"", "age"=>"30"}
53
+ # # Note: Strings are JSON-encoded with quotes
54
+ #
55
+ # @note This is an internal method used by commit_fields and hmset
56
+ # @note Nil values are excluded to optimize Redis storage
57
+ #
58
+ def to_h_for_storage
59
+ self.class.persistent_fields.each_with_object({}) do |field, hsh|
60
+ field_type = self.class.field_types[field]
61
+
62
+ val = send(field_type.method_name)
63
+ prepared = serialize_value(val)
64
+
65
+ if Familia.debug?
66
+ Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
67
+ end
68
+
69
+ # Use string key for database compatibility
70
+ hsh[field.to_s] = prepared
71
+ end
72
+ end
73
+
74
+ # Converts the object's persistent fields to an array.
75
+ #
76
+ # Serializes all persistent field values in field definition order,
77
+ # preparing them for Valkey storage. Each value is processed through
78
+ # the serialization pipeline to ensure Valkey compatibility.
79
+ #
80
+ # @return [Array] Array of serialized field values in field order
81
+ #
82
+ # @example Converting an object to array format
83
+ # user = User.new(name: "John", email: "john@example.com", age: 30)
84
+ # user.to_a
85
+ # # => ["John", "john@example.com", "30"]
86
+ #
87
+ # @note Values are serialized using the same process as other persistence
88
+ # methods to maintain data consistency across operations.
89
+ #
90
+ def to_a
91
+ self.class.persistent_fields.map do |field|
92
+ field_type = self.class.field_types[field]
93
+
94
+ # Security: Skip non-loggable fields (e.g., encrypted fields)
95
+ next unless field_type.loggable
96
+
97
+ method_name = field_type.method_name
98
+ val = send(method_name)
99
+ Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class}"
100
+
101
+ # Return actual Ruby values, including nil to maintain array positions
102
+ val
103
+ end
104
+ end
105
+
106
+ # Serializes a Ruby object for Valkey storage.
107
+ #
108
+ # Converts ALL Ruby values (including strings) to JSON-encoded strings for
109
+ # type-safe storage. This ensures round-trip type preservation: the type you
110
+ # save is the type you get back.
111
+ #
112
+ # The serialization process:
113
+ # 1. ConcealedStrings (encrypted values) → extract encrypted_value
114
+ # 2. ALL other types → JSON serialization (String, Integer, Boolean, Float, nil, Hash, Array)
115
+ #
116
+ # @param val [Object] The Ruby object to serialize for Valkey storage
117
+ #
118
+ # @return [String] JSON-encoded string representation
119
+ #
120
+ # @example Type preservation through JSON encoding
121
+ # serialize_value("007") # => "\"007\"" (JSON string)
122
+ # serialize_value(123) # => "123" (JSON number)
123
+ # serialize_value(true) # => "true" (JSON boolean)
124
+ # serialize_value({a: 1}) # => "{\"a\":1}" (JSON object)
125
+ #
126
+ # @note Strings are JSON-encoded to prevent type coercion bugs where
127
+ # string "123" would be indistinguishable from integer 123 in storage
128
+ #
129
+ # @note This method integrates with Familia's type system and supports
130
+ # custom serialization methods when available on the object
131
+ #
132
+ # @see Familia.identifier_extractor For extracting identifiers from Familia objects
133
+ #
134
+ def serialize_value(val)
135
+ # Security: Handle ConcealedString safely - extract encrypted data for storage
136
+ return val.encrypted_value if val.respond_to?(:encrypted_value)
137
+
138
+ # ALWAYS write valid JSON for type preservation
139
+ # This includes strings, which get JSON-encoded with wrapping quotes
140
+ Familia::JsonSerializer.dump(val)
141
+ end
142
+
143
+ # Converts a Redis string value back to its original Ruby type
144
+ #
145
+ # This method deserializes JSON strings back to their original Ruby types
146
+ # (Integer, Boolean, Float, nil, Hash, Array). Plain strings that cannot
147
+ # be parsed as JSON are returned as-is.
148
+ #
149
+ # This pairs with serialize_value which JSON-encodes all non-string values.
150
+ # The contract ensures type preservation across Redis storage:
151
+ # - Strings stored as-is → returned as-is
152
+ # - All other types JSON-encoded → JSON-decoded back to original type
153
+ #
154
+ # @param val [String] The string value from Redis to deserialize
155
+ # @param symbolize [Boolean] Whether to symbolize hash keys (default: false)
156
+ # @param field_name [Symbol, nil] Optional field name for better error context
157
+ # @return [Object] The deserialized value with original Ruby type, or the original string if not JSON
158
+ #
159
+ def deserialize_value(val, symbolize: false, field_name: nil)
160
+ return nil if val.nil? || val == ''
161
+
162
+ begin
163
+ Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
164
+ rescue Familia::SerializerError
165
+ log_deserialization_issue(val, field_name)
166
+ val
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def log_deserialization_issue(val, field_name)
173
+ context = field_name ? "#{self.class}##{field_name}" : self.class.to_s
174
+ dbkey_info = respond_to?(:dbkey) ? dbkey : 'no dbkey'
175
+
176
+ msg = if looks_like_json?(val)
177
+ "Corrupted JSON in #{context}: #{val.inspect} (#{dbkey_info})"
178
+ else
179
+ "Legacy plain string in #{context}: #{val.inspect} (#{dbkey_info})"
180
+ end
181
+
182
+ Familia.le(msg)
183
+ end
184
+
185
+ def looks_like_json?(val)
186
+ val.start_with?('{', '[', '"') || %w[true false null].include?(val)
187
+ end
188
+ end
189
+ end
190
+ end
@@ -1,9 +1,14 @@
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'
3
+ require_relative 'horreum/settings'
4
+ require_relative 'horreum/connection'
5
+ require_relative 'horreum/database_commands'
6
+ require_relative 'horreum/related_fields'
7
+ require_relative 'horreum/definition'
8
+ require_relative 'horreum/management'
9
+ require_relative 'horreum/persistence'
10
+ require_relative 'horreum/serialization'
11
+ require_relative 'horreum/utils'
7
12
 
8
13
  module Familia
9
14
  #
@@ -44,8 +49,12 @@ module Familia
44
49
  #
45
50
  class Horreum
46
51
  include Familia::Base
47
- include Familia::Horreum::Core
52
+ include Familia::Horreum::Persistence
53
+ include Familia::Horreum::Serialization
54
+ include Familia::Horreum::Connection
55
+ include Familia::Horreum::DatabaseCommands
48
56
  include Familia::Horreum::Settings
57
+ include Familia::Horreum::Utils
49
58
 
50
59
  using Familia::Refinements::TimeLiterals
51
60
 
@@ -223,6 +232,15 @@ module Familia
223
232
  init
224
233
  end
225
234
 
235
+ # Override this method in subclasses for custom initialization logic.
236
+ # This is called AFTER fields are set and relatives are initialized.
237
+ #
238
+ # DO NOT override initialize() - use this init() hook instead.
239
+ #
240
+ # Example:
241
+ # def init(name = nil)
242
+ # @name = name || SecureRandom.hex(4)
243
+ # end
226
244
  def init(*args, **kwargs)
227
245
  # Default no-op
228
246
  end
@@ -233,6 +251,8 @@ module Familia
233
251
  # This needs to be called in the initialize method.
234
252
  #
235
253
  def initialize_relatives
254
+ # Store initialization flag on singleton class to avoid polluting instance variables
255
+ return if singleton_class.instance_variable_defined?(:"@relatives_initialized")
236
256
  # Generate instances of each DataType. These need to be
237
257
  # unique for each instance of this class so they can piggyback
238
258
  # on the specifc index of this instance.
@@ -272,11 +292,16 @@ module Familia
272
292
  # e.g. customer.name #=> `#<Familia::HashKey:0x0000...>`
273
293
  instance_variable_set :"@#{name}", related_object
274
294
  end
295
+
296
+ # Mark relatives as initialized on singleton class to avoid polluting instance variables
297
+ singleton_class.instance_variable_set(:"@relatives_initialized", true)
275
298
  end
276
299
 
277
300
  def initialize_with_keyword_args_deserialize_value(**fields)
278
301
  # Deserialize Database string values back to their original types
279
- deserialized_fields = fields.transform_values { |value| deserialize_value(value) }
302
+ deserialized_fields = fields.each_with_object({}) do |(field_name, value), hsh|
303
+ hsh[field_name] = deserialize_value(value, field_name: field_name)
304
+ end
280
305
  initialize_with_keyword_args(**deserialized_fields)
281
306
  end
282
307
 
@@ -285,7 +310,7 @@ module Familia
285
310
  #
286
311
  # This method is part of horreum.rb rather than serialization.rb because it
287
312
  # operates solely on the provided values and doesn't query Database or other
288
- # external sources. That's why it's called "optimistic" refresh: it assumes
313
+ # external sources. That's why it's called "naive" refresh: it assumes
289
314
  # the provided values are correct and updates the object accordingly.
290
315
  #
291
316
  # @see #refresh!
@@ -293,8 +318,8 @@ module Familia
293
318
  # @param fields [Hash] A hash of field names and their new values to update
294
319
  # the object with.
295
320
  # @return [Array] The list of field names that were updated.
296
- def optimistic_refresh(**fields)
297
- Familia.ld "[optimistic_refresh] #{self.class} #{dbkey} #{fields.keys}"
321
+ def naive_refresh(**fields)
322
+ Familia.ld "[naive_refresh] #{self.class} #{dbkey} #{fields.keys}"
298
323
  initialize_with_keyword_args_deserialize_value(**fields)
299
324
  end
300
325
 
@@ -311,7 +336,7 @@ module Familia
311
336
  send(definition)
312
337
  when Proc
313
338
  definition.call(self)
314
- end
339
+ end
315
340
 
316
341
  # Return nil for unpopulated identifiers (like unsaved ActiveRecord objects)
317
342
  # Only raise errors when the identifier is actually needed for db operations
@@ -331,7 +356,6 @@ module Familia
331
356
  # @return [Redis] the Database connection instance.
332
357
  #
333
358
 
334
-
335
359
  def generate_id
336
360
  @objid ||= Familia.generate_id
337
361
  end
@@ -379,8 +403,10 @@ module Familia
379
403
  self.class.fields.filter_map do |field|
380
404
  # Database will give us field names as strings back, but internally
381
405
  # we use symbols. So we check for both.
382
- value = fields[field.to_sym] || fields[field.to_s]
383
- if value
406
+ # Use fetch with default to avoid || operator which skips false values
407
+ value = fields.fetch(field.to_sym) { fields[field.to_s] }
408
+ # Check for nil explicitly to allow false and 0 values
409
+ unless value.nil?
384
410
  # Use the mapped method name, not the field name
385
411
  method_name = self.class.field_method_map[field] || field
386
412
  send(:"#{method_name}=", value)
@@ -390,6 +416,5 @@ module Familia
390
416
  end
391
417
 
392
418
  # Builds the instance-level connection chain with handlers in priority order
393
-
394
419
  end
395
420
  end