familia 2.0.0.pre18 → 2.0.0.pre21

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 (370) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +4 -9
  3. data/.github/workflows/code-smells.yml +64 -3
  4. data/.pre-commit-config.yaml +8 -6
  5. data/.reek.yml +10 -9
  6. data/.rubocop.yml +4 -0
  7. data/CHANGELOG.rst +205 -88
  8. data/CLAUDE.md +62 -10
  9. data/Gemfile +3 -3
  10. data/Gemfile.lock +27 -62
  11. data/README.md +39 -0
  12. data/bin/try +16 -0
  13. data/bin/tryouts +16 -0
  14. data/changelog.d/20251105_flexible_external_identifier_format.rst +66 -0
  15. data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +44 -0
  16. data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +20 -0
  17. data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +91 -0
  18. data/changelog.d/20251107_optimized_redis_exists_checks.rst +94 -0
  19. data/changelog.d/20251108_frozen_string_literal_pragma.rst +44 -0
  20. data/docs/1106-participates_in-bidirectional-solution.md +129 -0
  21. data/docs/guides/encryption.md +486 -0
  22. data/docs/guides/feature-encrypted-fields.md +123 -7
  23. data/docs/guides/feature-expiration.md +177 -133
  24. data/docs/guides/feature-external-identifiers.md +415 -443
  25. data/docs/guides/feature-object-identifiers.md +400 -269
  26. data/docs/guides/feature-quantization.md +120 -6
  27. data/docs/guides/feature-relationships-indexing.md +318 -0
  28. data/docs/guides/feature-relationships-methods.md +146 -604
  29. data/docs/guides/feature-relationships-participation.md +263 -0
  30. data/docs/guides/feature-relationships.md +118 -136
  31. data/docs/guides/feature-system-devs.md +176 -693
  32. data/docs/guides/feature-system.md +119 -6
  33. data/docs/guides/feature-transient-fields.md +81 -0
  34. data/docs/guides/field-system.md +778 -0
  35. data/docs/guides/index.md +32 -15
  36. data/docs/guides/logging.md +187 -0
  37. data/docs/guides/optimized-loading.md +674 -0
  38. data/docs/guides/thread-safety-monitoring.md +61 -0
  39. data/docs/guides/{time-utilities.md → time-literals.md} +12 -12
  40. data/docs/migrating/v2.0.0-pre19.md +197 -0
  41. data/docs/migrating/v2.0.0-pre22.md +241 -0
  42. data/docs/overview.md +7 -9
  43. data/docs/reference/api-technical.md +267 -320
  44. data/examples/autoloader/mega_customer/features/deprecated_fields.rb +2 -0
  45. data/examples/autoloader/mega_customer/safe_dump_fields.rb +2 -0
  46. data/examples/autoloader/mega_customer.rb +2 -0
  47. data/examples/datatype_standalone.rb +282 -0
  48. data/examples/encrypted_fields.rb +2 -1
  49. data/examples/json_usage_patterns.rb +2 -0
  50. data/examples/relationships.rb +3 -0
  51. data/examples/safe_dump.rb +2 -1
  52. data/examples/sampling_demo.rb +53 -0
  53. data/examples/single_connection_transaction_confusions.rb +2 -1
  54. data/familia.gemspec +2 -1
  55. data/lib/familia/base.rb +2 -0
  56. data/lib/familia/connection/behavior.rb +254 -0
  57. data/lib/familia/connection/handlers.rb +97 -0
  58. data/lib/familia/connection/individual_command_proxy.rb +2 -0
  59. data/lib/familia/connection/middleware.rb +34 -24
  60. data/lib/familia/connection/operation_core.rb +3 -1
  61. data/lib/familia/connection/operations.rb +2 -0
  62. data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +4 -2
  63. data/lib/familia/connection/transaction_core.rb +75 -9
  64. data/lib/familia/connection.rb +21 -5
  65. data/lib/familia/data_type/class_methods.rb +3 -1
  66. data/lib/familia/data_type/connection.rb +153 -7
  67. data/lib/familia/data_type/database_commands.rb +9 -4
  68. data/lib/familia/data_type/serialization.rb +10 -4
  69. data/lib/familia/data_type/settings.rb +2 -0
  70. data/lib/familia/data_type/types/counter.rb +2 -0
  71. data/lib/familia/data_type/types/hashkey.rb +8 -6
  72. data/lib/familia/data_type/types/listkey.rb +2 -0
  73. data/lib/familia/data_type/types/lock.rb +2 -0
  74. data/lib/familia/data_type/types/sorted_set.rb +2 -0
  75. data/lib/familia/data_type/types/stringkey.rb +2 -0
  76. data/lib/familia/data_type/types/unsorted_set.rb +2 -0
  77. data/lib/familia/data_type.rb +2 -0
  78. data/lib/familia/encryption/encrypted_data.rb +4 -2
  79. data/lib/familia/encryption/manager.rb +2 -0
  80. data/lib/familia/encryption/provider.rb +2 -0
  81. data/lib/familia/encryption/providers/aes_gcm_provider.rb +2 -0
  82. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +2 -0
  83. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +2 -0
  84. data/lib/familia/encryption/registry.rb +2 -0
  85. data/lib/familia/encryption/request_cache.rb +2 -0
  86. data/lib/familia/encryption.rb +9 -2
  87. data/lib/familia/errors.rb +53 -14
  88. data/lib/familia/features/autoloader.rb +2 -0
  89. data/lib/familia/features/encrypted_fields/concealed_string.rb +2 -0
  90. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +4 -0
  91. data/lib/familia/features/encrypted_fields.rb +2 -2
  92. data/lib/familia/features/expiration/extensions.rb +11 -11
  93. data/lib/familia/features/expiration.rb +29 -21
  94. data/lib/familia/features/external_identifier.rb +33 -7
  95. data/lib/familia/features/object_identifier.rb +2 -0
  96. data/lib/familia/features/quantization.rb +3 -1
  97. data/lib/familia/features/relationships/README.md +3 -1
  98. data/lib/familia/features/relationships/collection_operations.rb +2 -0
  99. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +177 -47
  100. data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +479 -0
  101. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +203 -63
  102. data/lib/familia/features/relationships/indexing.rb +40 -42
  103. data/lib/familia/features/relationships/indexing_relationship.rb +17 -5
  104. data/lib/familia/features/relationships/participation/participant_methods.rb +131 -14
  105. data/lib/familia/features/relationships/participation/rebuild_strategies.md +41 -0
  106. data/lib/familia/features/relationships/participation/target_methods.rb +6 -6
  107. data/lib/familia/features/relationships/participation.rb +155 -69
  108. data/lib/familia/features/relationships/participation_membership.rb +69 -0
  109. data/lib/familia/features/relationships/participation_relationship.rb +34 -6
  110. data/lib/familia/features/relationships/score_encoding.rb +2 -0
  111. data/lib/familia/features/relationships.rb +5 -3
  112. data/lib/familia/features/safe_dump.rb +2 -0
  113. data/lib/familia/features/transient_fields/redacted_string.rb +2 -0
  114. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +2 -0
  115. data/lib/familia/features/transient_fields/transient_field_type.rb +5 -3
  116. data/lib/familia/features/transient_fields.rb +2 -0
  117. data/lib/familia/features.rb +2 -0
  118. data/lib/familia/field_type.rb +5 -2
  119. data/lib/familia/horreum/connection.rb +28 -36
  120. data/lib/familia/horreum/database_commands.rb +131 -10
  121. data/lib/familia/horreum/definition.rb +18 -7
  122. data/lib/familia/horreum/management.rb +233 -57
  123. data/lib/familia/horreum/persistence.rb +314 -122
  124. data/lib/familia/horreum/related_fields.rb +2 -0
  125. data/lib/familia/horreum/serialization.rb +26 -4
  126. data/lib/familia/horreum/settings.rb +2 -0
  127. data/lib/familia/horreum/utils.rb +2 -8
  128. data/lib/familia/horreum.rb +46 -13
  129. data/lib/familia/identifier_extractor.rb +2 -0
  130. data/lib/familia/instrumentation.rb +156 -0
  131. data/lib/familia/json_serializer.rb +2 -0
  132. data/lib/familia/logging.rb +94 -37
  133. data/lib/familia/refinements/dear_json.rb +2 -0
  134. data/lib/familia/refinements/stylize_words.rb +2 -14
  135. data/lib/familia/refinements/time_literals.rb +2 -0
  136. data/lib/familia/refinements.rb +2 -0
  137. data/lib/familia/secure_identifier.rb +10 -2
  138. data/lib/familia/settings.rb +9 -7
  139. data/lib/familia/thread_safety/instrumented_mutex.rb +166 -0
  140. data/lib/familia/thread_safety/monitor.rb +328 -0
  141. data/lib/familia/utils.rb +13 -0
  142. data/lib/familia/verifiable_identifier.rb +3 -1
  143. data/lib/familia/version.rb +3 -1
  144. data/lib/familia.rb +31 -4
  145. data/lib/middleware/database_command_counter.rb +152 -0
  146. data/lib/middleware/database_logger.rb +325 -129
  147. data/lib/multi_result.rb +2 -0
  148. data/try/edge_cases/empty_identifiers_try.rb +2 -0
  149. data/try/edge_cases/hash_symbolization_try.rb +2 -0
  150. data/try/edge_cases/json_serialization_try.rb +2 -0
  151. data/try/edge_cases/legacy_data_detection/deserialization_edge_cases_try.rb +4 -0
  152. data/try/edge_cases/race_conditions_try.rb +4 -0
  153. data/try/edge_cases/reserved_keywords_try.rb +4 -0
  154. data/try/edge_cases/string_coercion_try.rb +6 -4
  155. data/try/edge_cases/ttl_side_effects_try.rb +4 -0
  156. data/try/features/encrypted_fields/aad_protection_try.rb +4 -0
  157. data/try/features/encrypted_fields/concealed_string_core_try.rb +4 -0
  158. data/try/features/encrypted_fields/context_isolation_try.rb +4 -0
  159. data/try/features/encrypted_fields/encrypted_fields_core_try.rb +33 -0
  160. data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +4 -0
  161. data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +4 -0
  162. data/try/features/encrypted_fields/encrypted_fields_security_try.rb +4 -0
  163. data/try/features/encrypted_fields/error_conditions_try.rb +4 -0
  164. data/try/features/encrypted_fields/fresh_key_derivation_try.rb +4 -0
  165. data/try/features/encrypted_fields/fresh_key_try.rb +4 -0
  166. data/try/features/encrypted_fields/key_rotation_try.rb +4 -0
  167. data/try/features/encrypted_fields/memory_security_try.rb +4 -0
  168. data/try/features/encrypted_fields/missing_current_key_version_try.rb +4 -0
  169. data/try/features/encrypted_fields/nonce_uniqueness_try.rb +4 -0
  170. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +4 -0
  171. data/try/features/encrypted_fields/thread_safety_try.rb +4 -0
  172. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +4 -0
  173. data/try/features/encryption/config_persistence_try.rb +4 -0
  174. data/try/features/encryption/core_try.rb +4 -0
  175. data/try/features/encryption/instance_variable_scope_try.rb +4 -0
  176. data/try/features/encryption/module_loading_try.rb +4 -0
  177. data/try/features/encryption/providers/aes_gcm_provider_try.rb +4 -0
  178. data/try/features/encryption/providers/xchacha20_poly1305_provider_try.rb +4 -0
  179. data/try/features/encryption/roundtrip_validation_try.rb +4 -0
  180. data/try/features/encryption/secure_memory_handling_try.rb +4 -0
  181. data/try/features/expiration/expiration_try.rb +5 -1
  182. data/try/features/external_identifier/external_identifier_try.rb +171 -8
  183. data/try/features/feature_dependencies_try.rb +2 -0
  184. data/try/features/feature_improvements_try.rb +2 -0
  185. data/try/features/field_groups_try.rb +2 -0
  186. data/try/features/object_identifier/object_identifier_integration_try.rb +12 -9
  187. data/try/features/object_identifier/object_identifier_try.rb +2 -0
  188. data/try/features/quantization/quantization_try.rb +4 -0
  189. data/try/features/real_feature_integration_try.rb +2 -0
  190. data/try/features/relationships/indexing_commands_verification_try.rb +2 -0
  191. data/try/features/relationships/indexing_rebuild_try.rb +600 -0
  192. data/try/features/relationships/indexing_try.rb +30 -4
  193. data/try/features/relationships/participation_bidirectional_try.rb +242 -0
  194. data/try/features/relationships/participation_commands_verification_spec.rb +4 -0
  195. data/try/features/relationships/participation_commands_verification_try.rb +2 -0
  196. data/try/features/relationships/participation_performance_improvements_try.rb +11 -9
  197. data/try/features/relationships/participation_reverse_index_try.rb +15 -13
  198. data/try/features/relationships/participation_target_class_resolution_try.rb +209 -0
  199. data/try/features/relationships/participation_unresolved_target_try.rb +109 -0
  200. data/try/features/relationships/relationships_api_changes_try.rb +6 -4
  201. data/try/features/relationships/relationships_edge_cases_try.rb +4 -0
  202. data/try/features/relationships/relationships_performance_minimal_try.rb +4 -0
  203. data/try/features/relationships/relationships_performance_simple_try.rb +4 -0
  204. data/try/features/relationships/relationships_performance_try.rb +4 -0
  205. data/try/features/relationships/relationships_performance_working_try.rb +4 -0
  206. data/try/features/relationships/relationships_try.rb +6 -4
  207. data/try/features/safe_dump/safe_dump_advanced_try.rb +4 -0
  208. data/try/features/safe_dump/safe_dump_try.rb +4 -0
  209. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  210. data/try/features/transient_fields/refresh_reset_try.rb +3 -0
  211. data/try/features/transient_fields/simple_refresh_test.rb +3 -0
  212. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  213. data/try/features/transient_fields/transient_fields_core_try.rb +4 -0
  214. data/try/features/transient_fields/transient_fields_integration_try.rb +4 -0
  215. data/try/integration/connection/fiber_context_preservation_try.rb +7 -3
  216. data/try/integration/connection/handler_constraints_try.rb +4 -0
  217. data/try/integration/connection/isolated_dbclient_try.rb +4 -0
  218. data/try/integration/connection/middleware_reconnect_try.rb +2 -0
  219. data/try/integration/connection/operation_mode_guards_try.rb +5 -1
  220. data/try/integration/connection/pipeline_fallback_integration_try.rb +15 -12
  221. data/try/integration/connection/pools_try.rb +4 -0
  222. data/try/integration/connection/responsibility_chain_tracking_try.rb +4 -0
  223. data/try/integration/connection/transaction_fallback_integration_try.rb +4 -0
  224. data/try/integration/connection/transaction_mode_permissive_try.rb +4 -0
  225. data/try/integration/connection/transaction_mode_strict_try.rb +4 -0
  226. data/try/integration/connection/transaction_mode_warn_try.rb +4 -0
  227. data/try/integration/connection/transaction_modes_try.rb +4 -0
  228. data/try/integration/conventional_inheritance_try.rb +4 -0
  229. data/try/integration/create_method_try.rb +26 -22
  230. data/try/integration/cross_component_try.rb +4 -0
  231. data/try/integration/data_types/datatype_pipelines_try.rb +108 -0
  232. data/try/integration/data_types/datatype_transactions_try.rb +251 -0
  233. data/try/integration/database_consistency_try.rb +4 -0
  234. data/try/integration/familia_extended_try.rb +4 -0
  235. data/try/integration/familia_members_methods_try.rb +4 -0
  236. data/try/integration/models/customer_safe_dump_try.rb +9 -1
  237. data/try/integration/models/customer_try.rb +4 -0
  238. data/try/integration/models/datatype_base_try.rb +4 -0
  239. data/try/integration/models/familia_object_try.rb +5 -1
  240. data/try/integration/persistence_operations_try.rb +166 -10
  241. data/try/integration/relationships_persistence_round_trip_try.rb +17 -14
  242. data/try/integration/save_methods_consistency_try.rb +241 -0
  243. data/try/integration/scenarios_try.rb +4 -0
  244. data/try/integration/secure_identifier_try.rb +4 -0
  245. data/try/integration/transaction_safety_core_try.rb +176 -0
  246. data/try/integration/transaction_safety_workflow_try.rb +291 -0
  247. data/try/integration/verifiable_identifier_try.rb +4 -0
  248. data/try/investigation/pipeline_routing/README.md +228 -0
  249. data/try/performance/benchmarks_try.rb +4 -0
  250. data/try/performance/transaction_safety_benchmark_try.rb +238 -0
  251. data/try/support/benchmarks/deserialization_benchmark.rb +3 -1
  252. data/try/support/benchmarks/deserialization_correctness_test.rb +3 -1
  253. data/try/support/debugging/cache_behavior_tracer.rb +4 -0
  254. data/try/support/debugging/debug_aad_process.rb +3 -0
  255. data/try/support/debugging/debug_concealed_internal.rb +3 -0
  256. data/try/support/debugging/debug_concealed_reveal.rb +3 -0
  257. data/try/support/debugging/debug_context_aad.rb +3 -0
  258. data/try/support/debugging/debug_context_simple.rb +3 -0
  259. data/try/support/debugging/debug_cross_context.rb +3 -0
  260. data/try/support/debugging/debug_database_load.rb +3 -0
  261. data/try/support/debugging/debug_encrypted_json_check.rb +3 -0
  262. data/try/support/debugging/debug_encrypted_json_step_by_step.rb +3 -0
  263. data/try/support/debugging/debug_exists_lifecycle.rb +3 -0
  264. data/try/support/debugging/debug_field_decrypt.rb +3 -0
  265. data/try/support/debugging/debug_fresh_cross_context.rb +3 -0
  266. data/try/support/debugging/debug_load_path.rb +3 -0
  267. data/try/support/debugging/debug_method_definition.rb +3 -0
  268. data/try/support/debugging/debug_method_resolution.rb +3 -0
  269. data/try/support/debugging/debug_minimal.rb +3 -0
  270. data/try/support/debugging/debug_provider.rb +3 -0
  271. data/try/support/debugging/debug_secure_behavior.rb +3 -0
  272. data/try/support/debugging/debug_string_class.rb +3 -0
  273. data/try/support/debugging/debug_test.rb +3 -0
  274. data/try/support/debugging/debug_test_design.rb +3 -0
  275. data/try/support/debugging/encryption_method_tracer.rb +4 -0
  276. data/try/support/debugging/provider_diagnostics.rb +4 -0
  277. data/try/support/helpers/test_cleanup.rb +4 -0
  278. data/try/support/helpers/test_helpers.rb +5 -0
  279. data/try/support/memory/memory_basic_test.rb +4 -0
  280. data/try/support/memory/memory_detailed_test.rb +4 -0
  281. data/try/support/memory/memory_search_for_string.rb +4 -0
  282. data/try/support/memory/test_actual_redactedstring_protection.rb +4 -0
  283. data/try/support/prototypes/atomic_saves_v1_context_proxy.rb +4 -0
  284. data/try/support/prototypes/atomic_saves_v2_connection_switching.rb +4 -0
  285. data/try/support/prototypes/atomic_saves_v3_connection_pool.rb +4 -0
  286. data/try/support/prototypes/atomic_saves_v4.rb +4 -0
  287. data/try/support/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +4 -0
  288. data/try/support/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -0
  289. data/try/support/prototypes/pooling/configurable_stress_test.rb +4 -0
  290. data/try/support/prototypes/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -0
  291. data/try/support/prototypes/pooling/lib/connection_pool_metrics.rb +4 -0
  292. data/try/support/prototypes/pooling/lib/connection_pool_stress_test.rb +4 -0
  293. data/try/support/prototypes/pooling/lib/connection_pool_threading_models.rb +4 -0
  294. data/try/support/prototypes/pooling/lib/visualize_stress_results.rb +4 -2
  295. data/try/support/prototypes/pooling/pool_siege.rb +4 -2
  296. data/try/support/prototypes/pooling/run_stress_tests.rb +4 -2
  297. data/try/thread_safety/README.md +496 -0
  298. data/try/thread_safety/class_connection_chain_race_try.rb +265 -0
  299. data/try/thread_safety/connection_chain_race_try.rb +148 -0
  300. data/try/thread_safety/encryption_manager_cache_race_try.rb +166 -0
  301. data/try/thread_safety/feature_registry_race_try.rb +226 -0
  302. data/try/thread_safety/fiber_pipeline_isolation_try.rb +235 -0
  303. data/try/thread_safety/fiber_transaction_isolation_try.rb +208 -0
  304. data/try/thread_safety/field_registration_race_try.rb +222 -0
  305. data/try/thread_safety/logger_initialization_race_try.rb +170 -0
  306. data/try/thread_safety/middleware_registration_race_try.rb +154 -0
  307. data/try/thread_safety/module_config_race_try.rb +175 -0
  308. data/try/thread_safety/secure_identifier_cache_race_try.rb +226 -0
  309. data/try/unit/core/autoloader_try.rb +4 -0
  310. data/try/unit/core/base_enhancements_try.rb +4 -0
  311. data/try/unit/core/connection_try.rb +4 -0
  312. data/try/unit/core/errors_try.rb +4 -0
  313. data/try/unit/core/extensions_try.rb +4 -0
  314. data/try/unit/core/familia_logger_try.rb +2 -0
  315. data/try/unit/core/familia_try.rb +4 -0
  316. data/try/unit/core/middleware_sampling_try.rb +335 -0
  317. data/try/unit/core/middleware_test_helpers_bug_try.rb +58 -0
  318. data/try/unit/core/middleware_thread_safety_try.rb +245 -0
  319. data/try/unit/core/middleware_try.rb +4 -0
  320. data/try/unit/core/settings_try.rb +4 -0
  321. data/try/unit/core/time_utils_try.rb +4 -0
  322. data/try/unit/core/tools_try.rb +4 -0
  323. data/try/unit/core/utils_try.rb +37 -0
  324. data/try/unit/data_types/boolean_try.rb +5 -1
  325. data/try/unit/data_types/counter_try.rb +4 -0
  326. data/try/unit/data_types/datatype_base_try.rb +4 -0
  327. data/try/unit/data_types/hash_try.rb +4 -0
  328. data/try/unit/data_types/list_try.rb +4 -0
  329. data/try/unit/data_types/lock_try.rb +4 -0
  330. data/try/unit/data_types/sorted_set_try.rb +4 -0
  331. data/try/unit/data_types/sorted_set_zadd_options_try.rb +4 -0
  332. data/try/unit/data_types/string_try.rb +5 -1
  333. data/try/unit/data_types/unsortedset_try.rb +4 -0
  334. data/try/unit/familia_resolve_class_try.rb +116 -0
  335. data/try/unit/horreum/auto_indexing_on_save_try.rb +36 -16
  336. data/try/unit/horreum/automatic_index_validation_try.rb +255 -0
  337. data/try/unit/horreum/base_try.rb +5 -1
  338. data/try/unit/horreum/class_methods_try.rb +6 -2
  339. data/try/unit/horreum/commands_try.rb +4 -0
  340. data/try/unit/horreum/defensive_initialization_try.rb +4 -0
  341. data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +4 -0
  342. data/try/unit/horreum/enhanced_conflict_handling_try.rb +4 -0
  343. data/try/unit/horreum/field_categories_try.rb +4 -0
  344. data/try/unit/horreum/field_definition_try.rb +4 -0
  345. data/try/unit/horreum/initialization_try.rb +5 -1
  346. data/try/unit/horreum/json_type_preservation_try.rb +2 -0
  347. data/try/unit/horreum/optimized_loading_try.rb +156 -0
  348. data/try/unit/horreum/relations_try.rb +8 -4
  349. data/try/unit/horreum/serialization_persistent_fields_try.rb +4 -0
  350. data/try/unit/horreum/serialization_try.rb +6 -2
  351. data/try/unit/horreum/settings_try.rb +4 -0
  352. data/try/unit/horreum/unique_index_edge_cases_try.rb +380 -0
  353. data/try/unit/horreum/unique_index_guard_validation_try.rb +283 -0
  354. data/try/unit/middleware/database_command_counter_methods_try.rb +139 -0
  355. data/try/unit/middleware/database_logger_methods_try.rb +251 -0
  356. data/try/unit/refinements/dear_json_array_methods_try.rb +4 -0
  357. data/try/unit/refinements/dear_json_hash_methods_try.rb +4 -0
  358. data/try/unit/refinements/time_literals_numeric_methods_try.rb +4 -0
  359. data/try/unit/refinements/time_literals_string_methods_try.rb +4 -0
  360. data/try/unit/thread_safety_monitor_try.rb +149 -0
  361. metadata +81 -14
  362. data/.github/workflows/code-quality.yml +0 -138
  363. data/docs/archive/FAMILIA_RELATIONSHIPS.md +0 -210
  364. data/docs/archive/FAMILIA_TECHNICAL.md +0 -823
  365. data/docs/archive/FAMILIA_UPDATE.md +0 -226
  366. data/docs/archive/README.md +0 -64
  367. data/docs/archive/api-reference.md +0 -333
  368. data/docs/guides/core-field-system.md +0 -806
  369. data/docs/guides/implementation.md +0 -276
  370. data/docs/guides/security-model.md +0 -183
@@ -1,4 +1,6 @@
1
1
  # lib/familia/horreum/persistence.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Familia
4
6
  # Familia::Horreum
@@ -35,128 +37,187 @@ module Familia
35
37
  # Handles conversion between Ruby objects and Valkey hash storage
36
38
  #
37
39
  module Persistence
38
- # Persists the object to Valkey storage with automatic timestamping.
40
+ # Persists object state to storage with timestamps, validation, and indexing.
39
41
  #
40
- # Saves the current object state to Valkey storage, automatically setting
41
- # created and updated timestamps if the object supports them. The method
42
- # commits all persistent fields and optionally updates the key's expiration.
42
+ # Performs a complete save operation in an atomic transaction:
43
+ # - Sets created/updated timestamps
44
+ # - Validates unique index constraints
45
+ # - Persists all fields
46
+ # - Updates expiration (optional)
47
+ # - Updates class-level indexes
48
+ # - Adds to instances collection
43
49
  #
44
- # @param update_expiration [Boolean] Whether to update the key's expiration
45
- # time after saving. Defaults to true.
50
+ # ## Transaction Safety
51
+ #
52
+ # This method CANNOT be called within a transaction context. The save process
53
+ # requires reading current state to validate unique constraints, which would
54
+ # return uninspectable Redis::Future objects inside transactions.
55
+ #
56
+ # ### Correct Pattern:
57
+ # customer = Customer.new(email: 'test@example.com')
58
+ # customer.save # Validates unique constraints here
46
59
  #
47
- # @return [Boolean] true if the save operation was successful, false otherwise.
60
+ # customer.transaction do
61
+ # # Perform other atomic operations
62
+ # customer.increment(:login_count)
63
+ # customer.hset(:last_login, Time.now.to_i)
64
+ # end
48
65
  #
49
- # @example Save an object to Valkey
50
- # user = User.new(name: "John", email: "john@example.com")
51
- # user.save
52
- # # => true
66
+ # ### Incorrect Pattern:
67
+ # Customer.transaction do
68
+ # customer = Customer.new(email: 'test@example.com')
69
+ # customer.save # Raises Familia::OperationModeError
70
+ # end
53
71
  #
54
- # @example Save without updating expiration
55
- # user.save(update_expiration: false)
56
- # # => true
72
+ # @param update_expiration [Boolean] Whether to refresh key expiration (default: true)
73
+ # @return [Boolean] true on success
57
74
  #
58
- # @note When Familia.debug? is enabled, this method will trace the save
59
- # operation for debugging purposes.
75
+ # @raise [Familia::OperationModeError] If called within a transaction
76
+ # @raise [Familia::RecordExistsError] If unique index constraint violated
60
77
  #
61
- # @see #commit_fields The underlying method that performs the field persistence
78
+ # @example Basic usage
79
+ # user = User.new(email: "john@example.com")
80
+ # user.save # => true
81
+ #
82
+ # @see #save_if_not_exists! For conditional saves
83
+ # @see #transaction For atomic operations after save
62
84
  #
63
85
  def save(update_expiration: true)
64
- Familia.trace :SAVE, nil, uri if Familia.debug?
65
-
66
- # No longer need to sync computed identifier with a cache field
67
- self.created ||= Familia.now.to_i if respond_to?(:created)
68
- self.updated = Familia.now.to_i if respond_to?(:updated)
69
-
70
- # Commit our tale to the Database chronicles
71
- # Wrap in transaction for atomicity between save and indexing
72
- ret = commit_fields(update_expiration: update_expiration)
73
-
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
86
+ start_time = Familia.now_in_μs if Familia.debug?
87
+
88
+ # Prevent save within transaction - unique index guards require read operations
89
+ # which are not available in Redis MULTI/EXEC blocks
90
+ if Fiber[:familia_transaction]
91
+ raise Familia::OperationModeError, <<~ERROR_MESSAGE
92
+ Cannot call save within a transaction. Save operations must be called outside transactions to ensure unique constraints can be validated.
93
+ ERROR_MESSAGE
82
94
  end
83
95
 
84
- Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"
96
+ Familia.trace :SAVE, nil, self.class.uri if Familia.debug?
85
97
 
86
- # Did Database accept our offering?
87
- !ret.nil?
98
+ # Prepare object for persistence (timestamps, validation)
99
+ prepare_for_save
100
+
101
+ # Everything in ONE transaction for complete atomicity
102
+ result = transaction do |_conn|
103
+ persist_to_storage(update_expiration)
104
+ end
105
+
106
+ # Structured lifecycle logging and instrumentation
107
+ if Familia.debug? && start_time
108
+ duration = Familia.now_in_μs - start_time
109
+
110
+ begin
111
+ fields_count = to_h_for_storage.size
112
+ rescue => e
113
+ Familia.error "Failed to serialize fields for logging",
114
+ error: e.message,
115
+ class: self.class.name,
116
+ identifier: (identifier rescue nil)
117
+ fields_count = 0
118
+ end
119
+
120
+ Familia.debug "Horreum saved",
121
+ class: self.class.name,
122
+ identifier: identifier,
123
+ duration: duration,
124
+ fields_count: fields_count,
125
+ update_expiration: update_expiration
126
+
127
+ Familia::Instrumentation.notify_lifecycle(:save, self,
128
+ duration: duration,
129
+ update_expiration: update_expiration,
130
+ fields_count: fields_count
131
+ )
132
+ end
133
+
134
+ # Return boolean indicating success
135
+ !result.nil?
88
136
  end
89
137
 
90
- # Saves the object to Valkey storage only if it doesn't already exist.
91
- #
92
- # Conditionally persists the object to Valkey storage by first checking if the
93
- # identifier field already exists. If the object already exists in storage,
94
- # raises an error. Otherwise, proceeds with a normal save operation including
95
- # automatic timestamping.
96
- #
97
- # This method provides atomic conditional creation to prevent duplicate objects
98
- # from being saved when uniqueness is required based on the identifier field.
99
- #
100
- # @param update_expiration [Boolean] Whether to update the key's expiration
101
- # time after saving. Defaults to true.
102
- #
103
- # @return [Boolean] true if the save operation was successful
138
+ # Conditionally persists object only if it doesn't already exist in storage.
104
139
  #
105
- # @raise [Familia::RecordExistsError] If an object with the same identifier
106
- # already exists in Valkey storage
140
+ # Uses optimistic locking (WATCH) to atomically check existence and save.
141
+ # If the object doesn't exist, performs identical operations as save.
142
+ # If it exists, raises an error with retry logic for optimistic lock failures.
107
143
  #
108
- # @example Save a new user only if it doesn't exist
109
- # user = User.new(id: 123, name: "John")
110
- # user.save_if_not_exists
111
- # # => true (saved successfully)
112
- #
113
- # @example Attempting to save an existing object
114
- # existing_user = User.new(id: 123, name: "Jane")
115
- # existing_user.save_if_not_exists
116
- # # => raises Familia::RecordExistsError
117
- #
118
- # @example Save without updating expiration
119
- # user.save_if_not_exists(update_expiration: false)
120
- # # => true
121
- #
122
- # @note This method uses HSETNX to atomically check and set the identifier
123
- # field, ensuring race-condition-free conditional creation.
144
+ # `save_if_not_exists` doesn't call save because of the gap between checking
145
+ # existence and persisting the data. We can't check for existence inside the
146
+ # transaction because commands are queued and not executed until EXEC
147
+ # is called (if you try you get a Redis::Future object). So here we use a
148
+ # WATCH + MULTI/EXEC pattern to fail the transaction if the key is created
149
+ # (or modified in any way) to avoid silent data corruption♀︎.
150
+
151
+ # ♀︎ Additional note about WATCH + MULTI/EXEC in Valkey/Redis or any two
152
+ # step existence check in any database: although it is more cautious,
153
+ # it is not atomic. The only way to do that is if the database process
154
+ # can determine itself whether the record already exists or not. For
155
+ # Valkey/Redis, that means writing the lua to do that.
124
156
  #
125
- # @see #save The underlying save method called when the object doesn't exist
157
+ # @param update_expiration [Boolean] Whether to refresh key expiration (default: true)
158
+ # @return [Boolean] true on successful save
126
159
  #
127
- # Check if save_if_not_exists is implemented correctly. It should:
160
+ # @raise [Familia::RecordExistsError] If object already exists
161
+ # @raise [Familia::OptimisticLockError] If retries exhausted (max 3 attempts)
162
+ # @raise [Familia::OperationModeError] If called within a transaction
128
163
  #
129
- # Check if record exists
130
- # If exists, raise Familia::RecordExistsError
131
- # If not exists, save
132
- def save_if_not_exists(update_expiration: true)
164
+ # @example
165
+ # user = User.new(id: 123)
166
+ # user.save_if_not_exists! # => true or raises
167
+ def save_if_not_exists!(update_expiration: true)
168
+ # Prevent save_if_not_exists! within transaction - needs to read existence state
169
+ if Fiber[:familia_transaction]
170
+ raise Familia::OperationModeError, <<~ERROR_MESSAGE
171
+ Cannot call save_if_not_exists! within a transaction. This method
172
+ must be called outside transactions to properly check existence.
173
+ ERROR_MESSAGE
174
+ end
175
+
133
176
  identifier_field = self.class.identifier_field
134
177
 
135
- Familia.ld "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
136
- Familia.trace :SAVE_IF_NOT_EXISTS, nil, uri if Familia.debug?
178
+ Familia.debug "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
179
+ Familia.trace :SAVE_IF_NOT_EXISTS, nil, self.class.uri if Familia.debug?
137
180
 
138
- success = dbclient.watch(dbkey) do
139
- if dbclient.exists(dbkey).positive?
140
- dbclient.unwatch
141
- raise Familia::RecordExistsError, dbkey
142
- end
181
+ # Prepare object for persistence (timestamps, validation)
182
+ prepare_for_save
143
183
 
144
- result = dbclient.multi do |multi|
145
- multi.hmset(dbkey, to_h_for_storage)
146
- end
184
+ attempts = 0
185
+ begin
186
+ attempts += 1
147
187
 
148
- result.is_a?(Array) # transaction succeeded
149
- end
188
+ result = watch do
189
+ raise Familia::RecordExistsError, dbkey if exists?
150
190
 
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
191
+ txn_result = transaction do |_multi|
192
+ persist_to_storage(update_expiration)
193
+ end
194
+
195
+ Familia.debug "[save_if_not_exists]: txn_result=#{txn_result.inspect}"
196
+
197
+ txn_result
156
198
  end
199
+
200
+ Familia.debug "[save_if_not_exists]: result=#{result.inspect}"
201
+
202
+ # Return boolean indicating success (consistent with save method)
203
+ !result.nil?
204
+ rescue OptimisticLockError => e
205
+ Familia.debug "[save_if_not_exists]: OptimisticLockError (#{attempts}): #{e.message}"
206
+ raise if attempts >= 3
207
+
208
+ sleep(0.001 * (2**attempts))
209
+ retry
157
210
  end
211
+ end
158
212
 
159
- success
213
+ # Non-raising variant of save_if_not_exists!
214
+ #
215
+ # @return [Boolean] true on success, false if object exists
216
+ # @raise [Familia::OptimisticLockError] If concurrency conflict persists after retries
217
+ def save_if_not_exists(...)
218
+ save_if_not_exists!(...)
219
+ rescue RecordExistsError
220
+ false
160
221
  end
161
222
 
162
223
  # Commits object fields to the DB storage.
@@ -186,16 +247,17 @@ module Familia
186
247
  #
187
248
  def commit_fields(update_expiration: true)
188
249
  prepared_value = to_h_for_storage
189
- Familia.ld "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
250
+ Familia.debug "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
190
251
 
191
- result = hmset(prepared_value)
252
+ transaction do |_conn|
253
+ # Set all fields atomically
254
+ result = hmset(prepared_value)
192
255
 
193
- # Only classes that have the expiration ferature enabled will
194
- # actually set an expiration time on their keys. Otherwise
195
- # this will be a no-op that simply logs the attempt.
196
- update_expiration(default_expiration: nil) if update_expiration
256
+ # Update expiration in same transaction to ensure atomicity
257
+ self.update_expiration if result && update_expiration
197
258
 
198
- result
259
+ result
260
+ end
199
261
  end
200
262
 
201
263
  # Updates multiple fields atomically in a Database transaction.
@@ -216,20 +278,57 @@ module Familia
216
278
 
217
279
  Familia.trace :BATCH_UPDATE, nil, fields.keys if Familia.debug?
218
280
 
219
- transaction_result = transaction do |conn|
281
+ transaction do |_conn|
282
+ # 1. Update all fields atomically
220
283
  fields.each do |field, value|
221
284
  prepared_value = serialize_value(value)
222
- conn.hset dbkey, field, prepared_value
285
+ hset field, prepared_value
223
286
  # Update instance variable to keep object in sync
224
287
  send("#{field}=", value) if respond_to?("#{field}=")
225
288
  end
289
+
290
+ # 2. Update expiration in same transaction
291
+ self.update_expiration if update_expiration
226
292
  end
293
+ end
294
+
295
+ # Persists only the specified fields to Redis.
296
+ #
297
+ # Saves the current in-memory values of specified fields to Redis without
298
+ # modifying them first. Fields must already be set on the instance.
299
+ #
300
+ # @param field_names [Array<Symbol, String>] Names of fields to persist
301
+ # @param update_expiration [Boolean] Whether to refresh key expiration
302
+ # @return [self] Returns self for method chaining
303
+ #
304
+ # @example Persist only passphrase fields after updating them
305
+ # customer.update_passphrase('secret').save_fields(:passphrase, :passphrase_encryption)
306
+ #
307
+ def save_fields(*field_names, update_expiration: true)
308
+ raise ArgumentError, 'No fields specified' if field_names.empty?
227
309
 
228
- # Update expiration if requested and supported
229
- self.update_expiration(default_expiration: nil) if update_expiration && respond_to?(:update_expiration)
310
+ Familia.trace :SAVE_FIELDS, nil, field_names if Familia.debug?
230
311
 
231
- # Return the MultiResult directly (transaction already returns MultiResult)
232
- transaction_result
312
+ transaction do |_conn|
313
+ # Build hash of field names to serialized values
314
+ fields_hash = {}
315
+ field_names.each do |field|
316
+ field_sym = field.to_sym
317
+ raise ArgumentError, "Unknown field: #{field}" unless respond_to?(field_sym)
318
+
319
+ value = send(field_sym)
320
+ prepared_value = serialize_value(value)
321
+ fields_hash[field] = prepared_value
322
+ end
323
+
324
+ # Set all fields at once using hmset
325
+ hmset(fields_hash)
326
+
327
+ # Update expiration in same transaction
328
+ self.update_expiration if update_expiration
329
+ end
330
+
331
+ self
233
332
  end
234
333
 
235
334
  # Updates the object by applying multiple field values.
@@ -279,25 +378,35 @@ module Familia
279
378
  # @see #delete! The underlying method that performs the key deletion
280
379
  #
281
380
  def destroy!
282
- Familia.trace :DESTROY, dbkey, uri
381
+ Familia.trace :DESTROY!, dbkey, self.class.uri
283
382
 
284
383
  # Execute all deletion operations within a transaction
285
- transaction do |conn|
384
+ result = transaction do |_conn|
286
385
  # Delete the main object key
287
- conn.del(dbkey)
386
+ delete!
288
387
 
289
388
  # Delete all related fields if present
290
389
  if self.class.relations?
291
390
  Familia.trace :DELETE_RELATED_FIELDS!, nil,
292
391
  "#{self.class} has relations: #{self.class.related_fields.keys}"
293
392
 
294
- self.class.related_fields.each do |name, _definition|
393
+ self.class.related_fields.each_key do |name|
295
394
  obj = send(name)
296
395
  Familia.trace :DELETE_RELATED_FIELD, name, "Deleting related field #{name} (#{obj.dbkey})"
297
- conn.del(obj.dbkey)
396
+ obj.delete!
298
397
  end
299
398
  end
300
399
  end
400
+
401
+ # Structured lifecycle logging and instrumentation
402
+ Familia.debug "Horreum destroyed",
403
+ class: self.class.name,
404
+ identifier: identifier,
405
+ key: dbkey
406
+
407
+ Familia::Instrumentation.notify_lifecycle(:destroy, self, key: dbkey)
408
+
409
+ result
301
410
  end
302
411
 
303
412
  # Clears all fields by setting them to nil.
@@ -318,6 +427,7 @@ module Familia
318
427
  # after clear_fields! if you want to persist the cleared state.
319
428
  #
320
429
  def clear_fields!
430
+ Familia.trace :CLEAR_FIELDS!, dbkey, self.class.uri
321
431
  self.class.field_method_map.each_value { |method_name| send("#{method_name}=", nil) }
322
432
  end
323
433
 
@@ -343,11 +453,11 @@ module Familia
343
453
  # no authoritative source in Valkey storage.
344
454
  #
345
455
  def refresh!
346
- Familia.trace :REFRESH, nil, uri if Familia.debug?
456
+ Familia.trace :REFRESH, nil, self.class.uri if Familia.debug?
347
457
  raise Familia::KeyNotFoundError, dbkey unless dbclient.exists(dbkey)
348
458
 
349
459
  fields = hgetall
350
- Familia.ld "[refresh!] #{self.class} #{dbkey} fields:#{fields.keys}"
460
+ Familia.debug "[refresh!] #{self.class} #{dbkey} fields:#{fields.keys}"
351
461
 
352
462
  # Reset transient fields to nil for semantic clarity and ORM consistency
353
463
  # Transient fields have no authoritative source, so they should return to
@@ -378,6 +488,11 @@ module Familia
378
488
  self
379
489
  end
380
490
 
491
+ # Convenience methods that forward to the class method of the same name
492
+ def transaction(...) = self.class.transaction(...)
493
+ def pipelined(...) = self.class.pipelined(...)
494
+ def dbclient(...) = self.class.dbclient(...)
495
+
381
496
  private
382
497
 
383
498
  # Reset all transient fields to nil
@@ -398,16 +513,50 @@ module Familia
398
513
 
399
514
  # UnsortedSet the transient field back to nil
400
515
  send("#{field_type.method_name}=", nil)
401
- Familia.ld "[reset_transient_fields!] Reset #{field_name} to nil"
516
+ Familia.debug "[reset_transient_fields!] Reset #{field_name} to nil"
517
+ end
518
+ end
519
+
520
+ # Validates that unique index constraints are satisfied before saving
521
+ # This must be called OUTSIDE of transactions to allow reading current values
522
+ #
523
+ # @raise [Familia::RecordExistsError] If a unique index constraint is violated
524
+ # for any class-level unique_index relationships
525
+ #
526
+ # @note Only validates class-level unique indexes (without within: parameter).
527
+ # Instance-scoped indexes (with within:) are validated automatically when
528
+ # calling add_to_*_index methods:
529
+ #
530
+ # @example Instance-scoped indexes need to be called explicitly but when
531
+ # called they will perform the validation automatically:
532
+ # employee.add_to_company_badge_index(company) # raises on duplicate
533
+ #
534
+ # @return [void]
535
+ #
536
+ def guard_unique_indexes!
537
+ return unless self.class.respond_to?(:indexing_relationships)
538
+
539
+ self.class.indexing_relationships.each do |rel|
540
+ # Only validate unique indexes (not multi_index)
541
+ next unless rel.cardinality == :unique
542
+
543
+ # Only validate class-level indexes (skip instance-scoped)
544
+ next if rel.within
545
+
546
+ # Call the validation method if it exists
547
+ validate_method = :"guard_unique_#{rel.index_name}!"
548
+ send(validate_method) if respond_to?(validate_method)
402
549
  end
550
+
551
+ nil # Explicit nil return as documented
403
552
  end
404
553
 
405
554
  # Automatically update class-level indexes after save
406
555
  #
407
556
  # Iterates through class-level indexing relationships and calls their
408
557
  # 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.
558
+ # class-level indexes (where within is nil), skipping instance-scoped
559
+ # indexes which require scope context.
411
560
  #
412
561
  # Uses idempotent Redis commands (HSET for unique_index) so repeated calls
413
562
  # are safe and have negligible performance overhead. Note that multi_index
@@ -434,12 +583,12 @@ module Familia
434
583
  return unless self.class.respond_to?(:indexing_relationships)
435
584
 
436
585
  self.class.indexing_relationships.each do |rel|
437
- # Skip instance-scoped indexes (require parent context)
586
+ # Skip instance-scoped indexes (require scope context)
438
587
  # 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)
588
+ # the scope instance reference (e.g., employee.add_to_company_badge_index(company))
589
+ if rel.within
590
+ Familia.debug <<~LOG_MESSAGE
591
+ [auto_update_class_indexes] Skipping #{rel.index_name} (requires scope context)
443
592
  LOG_MESSAGE
444
593
  next
445
594
  end
@@ -450,6 +599,49 @@ module Familia
450
599
  end
451
600
  end
452
601
 
602
+ # Prepares the object for persistence by setting timestamps and validating constraints
603
+ #
604
+ # This method is called by both save and save_if_not_exists to ensure consistent
605
+ # preparation logic. It updates created/updated timestamps and validates unique
606
+ # indexes before the transaction begins.
607
+ #
608
+ # @return [void]
609
+ #
610
+ def prepare_for_save
611
+ # Update timestamp fields before saving
612
+ self.created ||= Familia.now if respond_to?(:created)
613
+ self.updated = Familia.now if respond_to?(:updated)
614
+
615
+ # Validate unique indexes BEFORE the transaction
616
+ guard_unique_indexes!
617
+ end
618
+ private :prepare_for_save
619
+
620
+ # Persists the object's data to storage within a transaction
621
+ #
622
+ # This method contains the core persistence logic shared by both save and
623
+ # save_if_not_exists. It must be called within a transaction block.
624
+ #
625
+ # @param update_expiration [Boolean] Whether to update the key's expiration
626
+ # @return [Object] The result of the hmset operation
627
+ #
628
+ def persist_to_storage(update_expiration)
629
+ # 1. Save all fields to hashkey at once
630
+ prepared_h = to_h_for_storage
631
+ hmset_result = hmset(prepared_h)
632
+
633
+ # 2. Set expiration in same transaction
634
+ self.update_expiration if update_expiration
635
+
636
+ # 3. Update class-level indexes
637
+ auto_update_class_indexes
638
+
639
+ # 4. Add to instances collection if available
640
+ self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
641
+
642
+ hmset_result
643
+ end
644
+ private :persist_to_storage
453
645
  end
454
646
  end
455
647
  end
@@ -1,4 +1,6 @@
1
1
  # lib/familia/horreum/related_fields.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Familia
4
6
 
@@ -1,4 +1,6 @@
1
1
  # lib/familia/horreum/serialization.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Familia
4
6
  class Horreum
@@ -30,7 +32,7 @@ module Familia
30
32
  next unless field_type.loggable
31
33
 
32
34
  val = send(field_type.method_name)
33
- Familia.ld " [to_h] field: #{field} val: #{val.class}"
35
+ Familia.debug " [to_h] field: #{field} val: #{val.class}"
34
36
 
35
37
  # Use string key for external API compatibility
36
38
  # Return Ruby values, not JSON-encoded strings
@@ -63,7 +65,7 @@ module Familia
63
65
  prepared = serialize_value(val)
64
66
 
65
67
  if Familia.debug?
66
- Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
68
+ Familia.debug " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
67
69
  end
68
70
 
69
71
  # Use string key for database compatibility
@@ -96,7 +98,7 @@ module Familia
96
98
 
97
99
  method_name = field_type.method_name
98
100
  val = send(method_name)
99
- Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class}"
101
+ Familia.debug " [to_a] field: #{field} method: #{method_name} val: #{val.class}"
100
102
 
101
103
  # Return actual Ruby values, including nil to maintain array positions
102
104
  val
@@ -159,6 +161,9 @@ module Familia
159
161
  def deserialize_value(val, symbolize: false, field_name: nil)
160
162
  return nil if val.nil? || val == ''
161
163
 
164
+ # Handle Redis::Future objects during transactions
165
+ return val if val.is_a?(Redis::Future)
166
+
162
167
  begin
163
168
  Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
164
169
  rescue Familia::SerializerError
@@ -179,7 +184,24 @@ module Familia
179
184
  "Legacy plain string in #{context}: #{val.inspect} (#{dbkey_info})"
180
185
  end
181
186
 
182
- Familia.le(msg)
187
+ # Structured error logging with instrumentation
188
+ error_type = looks_like_json?(val) ? :corrupted_json : :legacy_string
189
+ Familia.error msg,
190
+ error_type: error_type,
191
+ field: field_name,
192
+ value_preview: val.to_s[0...50],
193
+ object_class: self.class.name,
194
+ identifier: (identifier rescue nil),
195
+ key: dbkey_info
196
+
197
+ # Notify instrumentation hooks
198
+ Familia::Instrumentation.notify_error(
199
+ StandardError.new(msg),
200
+ operation: :deserialization,
201
+ error_type: error_type,
202
+ field: field_name,
203
+ object_class: self.class.name
204
+ )
183
205
  end
184
206
 
185
207
  def looks_like_json?(val)
@@ -1,4 +1,6 @@
1
1
  # lib/familia/horreum/settings.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Familia
4
6
  # InstanceMethods - Module containing instance-level methods for Familia