familia 2.0.0.pre17 → 2.0.0.pre19

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 (249) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +118 -6
  3. data/CLAUDE.md +43 -11
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +9 -47
  6. data/README.md +52 -0
  7. data/bin/irb +1 -1
  8. data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
  9. data/changelog.d/20251011_203905_delano_next.rst +30 -0
  10. data/changelog.d/20251011_212633_delano_next.rst +13 -0
  11. data/changelog.d/20251011_221253_delano_next.rst +26 -0
  12. data/docs/guides/core-field-system.md +48 -26
  13. data/docs/guides/feature-expiration.md +18 -18
  14. data/docs/migrating/v2.0.0-pre18.md +58 -0
  15. data/docs/migrating/v2.0.0-pre19.md +197 -0
  16. data/docs/qodo-merge-compliance.md +96 -0
  17. data/examples/datatype_standalone.rb +281 -0
  18. data/lib/familia/base.rb +0 -2
  19. data/lib/familia/connection/behavior.rb +252 -0
  20. data/lib/familia/connection/handlers.rb +95 -0
  21. data/lib/familia/connection/middleware.rb +58 -4
  22. data/lib/familia/connection/operation_core.rb +1 -1
  23. data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
  24. data/lib/familia/connection/transaction_core.rb +7 -9
  25. data/lib/familia/connection.rb +2 -1
  26. data/lib/familia/data_type/connection.rb +151 -7
  27. data/lib/familia/data_type/{commands.rb → database_commands.rb} +9 -6
  28. data/lib/familia/data_type/serialization.rb +9 -5
  29. data/lib/familia/data_type/types/hashkey.rb +1 -1
  30. data/lib/familia/data_type.rb +2 -2
  31. data/lib/familia/encryption/encrypted_data.rb +12 -2
  32. data/lib/familia/encryption/manager.rb +11 -4
  33. data/lib/familia/errors.rb +51 -14
  34. data/lib/familia/features/autoloader.rb +3 -1
  35. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +11 -3
  36. data/lib/familia/features/expiration/extensions.rb +8 -10
  37. data/lib/familia/features/expiration.rb +19 -19
  38. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +45 -44
  39. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +151 -65
  40. data/lib/familia/features/relationships/indexing.rb +37 -42
  41. data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
  42. data/lib/familia/features/safe_dump.rb +2 -3
  43. data/lib/familia/field_type.rb +2 -1
  44. data/lib/familia/horreum/connection.rb +11 -35
  45. data/lib/familia/horreum/database_commands.rb +130 -11
  46. data/lib/familia/horreum/definition.rb +8 -38
  47. data/lib/familia/horreum/management.rb +38 -27
  48. data/lib/familia/horreum/persistence.rb +191 -67
  49. data/lib/familia/horreum/serialization.rb +94 -73
  50. data/lib/familia/horreum/utils.rb +0 -8
  51. data/lib/familia/horreum.rb +41 -18
  52. data/lib/familia/identifier_extractor.rb +60 -0
  53. data/lib/familia/logging.rb +268 -112
  54. data/lib/familia/refinements.rb +0 -1
  55. data/lib/familia/settings.rb +7 -7
  56. data/lib/familia/version.rb +1 -1
  57. data/lib/familia.rb +2 -2
  58. data/lib/middleware/{database_middleware.rb → database_logger.rb} +118 -14
  59. data/pr_agent.toml +31 -0
  60. data/pr_compliance_checklist.yaml +45 -0
  61. data/try/edge_cases/empty_identifiers_try.rb +1 -1
  62. data/try/edge_cases/hash_symbolization_try.rb +31 -31
  63. data/try/edge_cases/json_serialization_try.rb +2 -2
  64. data/try/edge_cases/legacy_data_detection/deserialization_edge_cases_try.rb +170 -0
  65. data/try/edge_cases/race_conditions_try.rb +1 -1
  66. data/try/edge_cases/reserved_keywords_try.rb +1 -1
  67. data/try/edge_cases/string_coercion_try.rb +5 -5
  68. data/try/edge_cases/ttl_side_effects_try.rb +1 -1
  69. data/try/features/encrypted_fields/aad_protection_try.rb +1 -1
  70. data/try/features/encrypted_fields/concealed_string_core_try.rb +1 -1
  71. data/try/features/encrypted_fields/context_isolation_try.rb +1 -1
  72. data/try/features/encrypted_fields/encrypted_fields_core_try.rb +1 -1
  73. data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +1 -1
  74. data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +1 -1
  75. data/try/features/encrypted_fields/encrypted_fields_security_try.rb +1 -1
  76. data/try/features/encrypted_fields/error_conditions_try.rb +1 -1
  77. data/try/features/encrypted_fields/fresh_key_derivation_try.rb +1 -1
  78. data/try/features/encrypted_fields/fresh_key_try.rb +1 -1
  79. data/try/features/encrypted_fields/key_rotation_try.rb +1 -1
  80. data/try/features/encrypted_fields/memory_security_try.rb +1 -1
  81. data/try/features/encrypted_fields/missing_current_key_version_try.rb +1 -1
  82. data/try/features/encrypted_fields/nonce_uniqueness_try.rb +1 -1
  83. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +1 -1
  84. data/try/features/encrypted_fields/thread_safety_try.rb +1 -1
  85. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +1 -1
  86. data/try/{encryption → features/encryption}/config_persistence_try.rb +1 -1
  87. data/try/{encryption/encryption_core_try.rb → features/encryption/core_try.rb} +2 -2
  88. data/try/{encryption → features/encryption}/instance_variable_scope_try.rb +1 -1
  89. data/try/{encryption → features/encryption}/module_loading_try.rb +1 -1
  90. data/try/{encryption → features/encryption}/providers/aes_gcm_provider_try.rb +1 -1
  91. data/try/{encryption → features/encryption}/providers/xchacha20_poly1305_provider_try.rb +1 -1
  92. data/try/{encryption → features/encryption}/roundtrip_validation_try.rb +1 -1
  93. data/try/{encryption → features/encryption}/secure_memory_handling_try.rb +2 -2
  94. data/try/features/expiration/expiration_try.rb +2 -2
  95. data/try/features/external_identifier/external_identifier_try.rb +1 -1
  96. data/try/features/feature_dependencies_try.rb +1 -1
  97. data/try/features/feature_improvements_try.rb +1 -1
  98. data/try/features/object_identifier/object_identifier_integration_try.rb +1 -1
  99. data/try/features/object_identifier/object_identifier_try.rb +1 -1
  100. data/try/features/quantization/quantization_try.rb +1 -1
  101. data/try/features/real_feature_integration_try.rb +17 -14
  102. data/try/features/relationships/indexing_commands_verification_try.rb +8 -3
  103. data/try/features/relationships/indexing_try.rb +34 -5
  104. data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
  105. data/try/features/relationships/participation_commands_verification_try.rb +4 -4
  106. data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
  107. data/try/features/relationships/participation_reverse_index_try.rb +1 -1
  108. data/try/features/relationships/relationships_api_changes_try.rb +5 -5
  109. data/try/features/relationships/relationships_edge_cases_try.rb +3 -3
  110. data/try/features/relationships/relationships_performance_minimal_try.rb +1 -1
  111. data/try/features/relationships/relationships_performance_simple_try.rb +1 -1
  112. data/try/features/relationships/relationships_performance_try.rb +1 -1
  113. data/try/features/relationships/relationships_performance_working_try.rb +1 -1
  114. data/try/features/relationships/relationships_try.rb +1 -1
  115. data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
  116. data/try/features/safe_dump/safe_dump_try.rb +1 -1
  117. data/try/features/transient_fields/redacted_string_try.rb +1 -1
  118. data/try/features/transient_fields/refresh_reset_try.rb +1 -1
  119. data/try/features/transient_fields/single_use_redacted_string_try.rb +1 -1
  120. data/try/features/transient_fields/transient_fields_core_try.rb +1 -1
  121. data/try/features/transient_fields/transient_fields_integration_try.rb +1 -1
  122. data/try/{connection → integration/connection}/fiber_context_preservation_try.rb +4 -4
  123. data/try/{connection → integration/connection}/handler_constraints_try.rb +1 -1
  124. data/try/{core → integration/connection}/isolated_dbclient_try.rb +1 -1
  125. data/try/integration/connection/middleware_reconnect_try.rb +87 -0
  126. data/try/{connection → integration/connection}/operation_mode_guards_try.rb +2 -2
  127. data/try/{connection → integration/connection}/pipeline_fallback_integration_try.rb +13 -13
  128. data/try/{core → integration/connection}/pools_try.rb +1 -1
  129. data/try/{connection → integration/connection}/responsibility_chain_tracking_try.rb +1 -1
  130. data/try/{connection → integration/connection}/transaction_fallback_integration_try.rb +1 -1
  131. data/try/{connection → integration/connection}/transaction_mode_permissive_try.rb +1 -1
  132. data/try/{connection → integration/connection}/transaction_mode_strict_try.rb +1 -1
  133. data/try/{connection → integration/connection}/transaction_mode_warn_try.rb +1 -1
  134. data/try/{connection → integration/connection}/transaction_modes_try.rb +1 -1
  135. data/try/{core → integration}/conventional_inheritance_try.rb +1 -1
  136. data/try/{core → integration}/create_method_try.rb +23 -23
  137. data/try/integration/cross_component_try.rb +1 -1
  138. data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
  139. data/try/integration/data_types/datatype_transactions_try.rb +247 -0
  140. data/try/{core → integration}/database_consistency_try.rb +11 -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 +6 -2
  144. data/try/{models → integration/models}/customer_try.rb +1 -1
  145. data/try/{models → integration/models}/datatype_base_try.rb +1 -1
  146. data/try/{models → integration/models}/familia_object_try.rb +2 -2
  147. data/try/{core → integration}/persistence_operations_try.rb +163 -11
  148. data/try/integration/relationships_persistence_round_trip_try.rb +441 -0
  149. data/try/{configuration → integration}/scenarios_try.rb +1 -1
  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 +12 -3
  156. data/try/{core → unit/core}/autoloader_try.rb +1 -1
  157. data/try/{core → unit/core}/base_enhancements_try.rb +1 -9
  158. data/try/{core → unit/core}/connection_try.rb +1 -1
  159. data/try/{core → unit/core}/errors_try.rb +1 -1
  160. data/try/{core → unit/core}/extensions_try.rb +1 -1
  161. data/try/unit/core/familia_logger_try.rb +110 -0
  162. data/try/{core → unit/core}/familia_try.rb +1 -1
  163. data/try/{core → unit/core}/middleware_try.rb +41 -1
  164. data/try/{core → unit/core}/settings_try.rb +1 -1
  165. data/try/{core → unit/core}/time_utils_try.rb +1 -1
  166. data/try/{core → unit/core}/tools_try.rb +1 -1
  167. data/try/{core → unit/core}/utils_try.rb +17 -14
  168. data/try/{data_types → unit/data_types}/boolean_try.rb +2 -2
  169. data/try/{data_types → unit/data_types}/counter_try.rb +1 -1
  170. data/try/{data_types → unit/data_types}/datatype_base_try.rb +1 -1
  171. data/try/{data_types → unit/data_types}/hash_try.rb +1 -1
  172. data/try/{data_types → unit/data_types}/list_try.rb +1 -1
  173. data/try/{data_types → unit/data_types}/lock_try.rb +1 -1
  174. data/try/{data_types → unit/data_types}/sorted_set_try.rb +1 -1
  175. data/try/{data_types → unit/data_types}/sorted_set_zadd_options_try.rb +1 -1
  176. data/try/{data_types → unit/data_types}/string_try.rb +2 -2
  177. data/try/{data_types → unit/data_types}/unsortedset_try.rb +1 -1
  178. data/try/{horreum → unit/horreum}/auto_indexing_on_save_try.rb +33 -17
  179. data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
  180. data/try/{horreum → unit/horreum}/base_try.rb +4 -4
  181. data/try/{horreum → unit/horreum}/class_methods_try.rb +3 -3
  182. data/try/{horreum → unit/horreum}/commands_try.rb +1 -1
  183. data/try/{horreum → unit/horreum}/defensive_initialization_try.rb +1 -1
  184. data/try/{horreum → unit/horreum}/destroy_related_fields_cleanup_try.rb +1 -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 +3 -3
  189. data/try/unit/horreum/json_type_preservation_try.rb +248 -0
  190. data/try/{horreum → unit/horreum}/relations_try.rb +5 -5
  191. data/try/{horreum → unit/horreum}/serialization_persistent_fields_try.rb +24 -18
  192. data/try/{horreum → unit/horreum}/serialization_try.rb +6 -6
  193. data/try/{horreum → unit/horreum}/settings_try.rb +1 -1
  194. data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
  195. data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
  196. data/try/{refinements → unit/refinements}/dear_json_array_methods_try.rb +1 -1
  197. data/try/{refinements → unit/refinements}/dear_json_hash_methods_try.rb +1 -1
  198. data/try/{refinements → unit/refinements}/time_literals_numeric_methods_try.rb +1 -1
  199. data/try/{refinements → unit/refinements}/time_literals_string_methods_try.rb +1 -1
  200. metadata +147 -126
  201. data/lib/familia/distinguisher.rb +0 -85
  202. data/lib/familia/refinements/logger_trace.rb +0 -60
  203. data/try/refinements/logger_trace_methods_try.rb +0 -44
  204. /data/try/{debugging → support/debugging}/README.md +0 -0
  205. /data/try/{debugging → support/debugging}/cache_behavior_tracer.rb +0 -0
  206. /data/try/{debugging → support/debugging}/debug_aad_process.rb +0 -0
  207. /data/try/{debugging → support/debugging}/debug_concealed_internal.rb +0 -0
  208. /data/try/{debugging → support/debugging}/debug_concealed_reveal.rb +0 -0
  209. /data/try/{debugging → support/debugging}/debug_context_aad.rb +0 -0
  210. /data/try/{debugging → support/debugging}/debug_context_simple.rb +0 -0
  211. /data/try/{debugging → support/debugging}/debug_cross_context.rb +0 -0
  212. /data/try/{debugging → support/debugging}/debug_database_load.rb +0 -0
  213. /data/try/{debugging → support/debugging}/debug_encrypted_json_check.rb +0 -0
  214. /data/try/{debugging → support/debugging}/debug_encrypted_json_step_by_step.rb +0 -0
  215. /data/try/{debugging → support/debugging}/debug_exists_lifecycle.rb +0 -0
  216. /data/try/{debugging → support/debugging}/debug_field_decrypt.rb +0 -0
  217. /data/try/{debugging → support/debugging}/debug_fresh_cross_context.rb +0 -0
  218. /data/try/{debugging → support/debugging}/debug_load_path.rb +0 -0
  219. /data/try/{debugging → support/debugging}/debug_method_definition.rb +0 -0
  220. /data/try/{debugging → support/debugging}/debug_method_resolution.rb +0 -0
  221. /data/try/{debugging → support/debugging}/debug_minimal.rb +0 -0
  222. /data/try/{debugging → support/debugging}/debug_provider.rb +0 -0
  223. /data/try/{debugging → support/debugging}/debug_secure_behavior.rb +0 -0
  224. /data/try/{debugging → support/debugging}/debug_string_class.rb +0 -0
  225. /data/try/{debugging → support/debugging}/debug_test.rb +0 -0
  226. /data/try/{debugging → support/debugging}/debug_test_design.rb +0 -0
  227. /data/try/{debugging → support/debugging}/encryption_method_tracer.rb +0 -0
  228. /data/try/{debugging → support/debugging}/provider_diagnostics.rb +0 -0
  229. /data/try/{helpers → support/helpers}/test_cleanup.rb +0 -0
  230. /data/try/{memory → support/memory}/memory_basic_test.rb +0 -0
  231. /data/try/{memory → support/memory}/memory_detailed_test.rb +0 -0
  232. /data/try/{memory → support/memory}/memory_docker_ruby_dump.sh +0 -0
  233. /data/try/{memory → support/memory}/memory_search_for_string.rb +0 -0
  234. /data/try/{memory → support/memory}/test_actual_redactedstring_protection.rb +0 -0
  235. /data/try/{prototypes → support/prototypes}/atomic_saves_v1_context_proxy.rb +0 -0
  236. /data/try/{prototypes → support/prototypes}/atomic_saves_v2_connection_switching.rb +0 -0
  237. /data/try/{prototypes → support/prototypes}/atomic_saves_v3_connection_pool.rb +0 -0
  238. /data/try/{prototypes → support/prototypes}/atomic_saves_v4.rb +0 -0
  239. /data/try/{prototypes → support/prototypes}/lib/atomic_saves_v2_connection_switching_helpers.rb +0 -0
  240. /data/try/{prototypes → support/prototypes}/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
  241. /data/try/{prototypes → support/prototypes}/pooling/README.md +0 -0
  242. /data/try/{prototypes → support/prototypes}/pooling/configurable_stress_test.rb +0 -0
  243. /data/try/{prototypes → support/prototypes}/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +0 -0
  244. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_metrics.rb +0 -0
  245. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_stress_test.rb +0 -0
  246. /data/try/{prototypes → support/prototypes}/pooling/lib/connection_pool_threading_models.rb +0 -0
  247. /data/try/{prototypes → support/prototypes}/pooling/lib/visualize_stress_results.rb +0 -0
  248. /data/try/{prototypes → support/prototypes}/pooling/pool_siege.rb +0 -0
  249. /data/try/{prototypes → support/prototypes}/pooling/run_stress_tests.rb +0 -0
@@ -35,17 +35,23 @@ module Familia
35
35
  # Handles conversion between Ruby objects and Valkey hash storage
36
36
  #
37
37
  module Persistence
38
- # Persists the object to Valkey storage with automatic timestamping.
38
+ # Persists the object to Valkey storage with automatic timestamping and validation.
39
39
  #
40
40
  # Saves the current object state to Valkey storage, automatically setting
41
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
+ # validates unique indexes before the transaction, commits all persistent
43
+ # fields, and optionally updates the key's expiration.
43
44
  #
44
45
  # @param update_expiration [Boolean] Whether to update the key's expiration
45
46
  # time after saving. Defaults to true.
46
47
  #
47
48
  # @return [Boolean] true if the save operation was successful, false otherwise.
48
49
  #
50
+ # @raise [Familia::OperationModeError] If called within an existing transaction.
51
+ # Guards need to read current values, which is not possible inside MULTI/EXEC.
52
+ # @raise [Familia::RecordExistsError] If a unique index constraint is violated
53
+ # for any class-level unique_index relationships.
54
+ #
49
55
  # @example Save an object to Valkey
50
56
  # user = User.new(name: "John", email: "john@example.com")
51
57
  # user.save
@@ -55,36 +61,60 @@ module Familia
55
61
  # user.save(update_expiration: false)
56
62
  # # => true
57
63
  #
64
+ # @example Handle duplicate unique index
65
+ # user2 = User.new(name: "Jane", email: "john@example.com")
66
+ # user2.save
67
+ # # => raises Familia::RecordExistsError
68
+ #
69
+ # @note Cannot be called within a transaction. Call save first to start
70
+ # the transaction, or use commit_fields/hmset for manual field updates
71
+ # within transactions.
72
+ #
58
73
  # @note When Familia.debug? is enabled, this method will trace the save
59
74
  # operation for debugging purposes.
60
75
  #
61
76
  # @see #commit_fields The underlying method that performs the field persistence
77
+ # @see #guard_unique_indexes! Automatic validation of class-level unique indexes
62
78
  #
63
79
  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
80
+ # Prevent save within transaction - unique index guards require read operations
81
+ # which are not available in Redis MULTI/EXEC blocks
82
+ if Fiber[:familia_transaction]
83
+ raise Familia::OperationModeError,
84
+ "Cannot call save within a transaction. Save operations must be called outside transactions to ensure unique constraints can be validated."
82
85
  end
83
86
 
84
- Familia.ld "[save] #{self.class} #{dbkey} #{ret} (update_expiration: #{update_expiration})"
87
+ Familia.trace :SAVE, nil, self.class.uri if Familia.debug?
88
+
89
+ # Update timestamp fields before saving
90
+ self.created ||= Familia.now if respond_to?(:created)
91
+ self.updated = Familia.now if respond_to?(:updated)
92
+
93
+ # Validate unique indexes BEFORE the transaction
94
+ guard_unique_indexes!
95
+
96
+ # Everything in ONE transaction for complete atomicity
97
+ result = transaction do |_conn|
98
+ # 1. Save all fields
99
+ prepared_h = to_h_for_storage
100
+ hmset_result = hmset(prepared_h)
85
101
 
86
- # Did Database accept our offering?
87
- !ret.nil?
102
+ # 2. Set expiration in same transaction
103
+ self.update_expiration if update_expiration
104
+
105
+ # 3. Update class-level indexes
106
+ auto_update_class_indexes
107
+
108
+ # 4. Add to instances collection if available
109
+ self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
110
+
111
+ hmset_result
112
+ end
113
+
114
+ Familia.ld "[save] #{self.class} #{dbkey} #{result} (update_expiration: #{update_expiration})"
115
+
116
+ # Return boolean indicating success
117
+ !result.nil?
88
118
  end
89
119
 
90
120
  # Saves the object to Valkey storage only if it doesn't already exist.
@@ -129,34 +159,51 @@ module Familia
129
159
  # Check if record exists
130
160
  # If exists, raise Familia::RecordExistsError
131
161
  # If not exists, save
132
- def save_if_not_exists(update_expiration: true)
162
+ def save_if_not_exists!(update_expiration: true)
163
+ # Prevent save_if_not_exists! within transaction - needs to read existence state
164
+ if Fiber[:familia_transaction]
165
+ raise Familia::OperationModeError,
166
+ "Cannot call save_if_not_exists! within a transaction. This method must be called outside transactions to properly check existence."
167
+ end
168
+
133
169
  identifier_field = self.class.identifier_field
134
170
 
135
171
  Familia.ld "[save_if_not_exists]: #{self.class} #{identifier_field}=#{identifier}"
136
- Familia.trace :SAVE_IF_NOT_EXISTS, nil, uri if Familia.debug?
172
+ Familia.trace :SAVE_IF_NOT_EXISTS, nil, self.class.uri if Familia.debug?
137
173
 
138
- success = dbclient.watch(dbkey) do
139
- if dbclient.exists(dbkey).positive?
140
- dbclient.unwatch
141
- raise Familia::RecordExistsError, dbkey
142
- end
174
+ attempts = 0
175
+ begin
176
+ attempts += 1
143
177
 
144
- result = dbclient.multi do |multi|
145
- multi.hmset(dbkey, to_h_for_storage)
146
- end
178
+ watch do
179
+ raise Familia::RecordExistsError, dbkey if exists?
147
180
 
148
- result.is_a?(Array) # transaction succeeded
149
- end
181
+ txn_result = transaction do |_multi|
182
+ hmset(to_h_for_storage)
183
+
184
+ self.update_expiration if update_expiration
185
+
186
+ # Auto-index for class-level indexes after successful save
187
+ auto_update_class_indexes
188
+ end
150
189
 
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
190
+ Familia.ld "[save_if_not_exists]: txn_result=#{txn_result.inspect}"
191
+
192
+ txn_result.successful?
156
193
  end
194
+ rescue OptimisticLockError => e
195
+ Familia.ld "[save_if_not_exists]: OptimisticLockError (#{attempts}): #{e.message}"
196
+ raise if attempts >= 3
197
+
198
+ sleep(0.001 * (2**attempts))
199
+ retry
157
200
  end
201
+ end
158
202
 
159
- success
203
+ def save_if_not_exists(...)
204
+ save_if_not_exists!(...)
205
+ rescue RecordExistsError, OptimisticLockError
206
+ false
160
207
  end
161
208
 
162
209
  # Commits object fields to the DB storage.
@@ -188,14 +235,15 @@ module Familia
188
235
  prepared_value = to_h_for_storage
189
236
  Familia.ld "[commit_fields] Begin #{self.class} #{dbkey} #{prepared_value} (exp: #{update_expiration})"
190
237
 
191
- result = hmset(prepared_value)
238
+ transaction do |_conn|
239
+ # Set all fields atomically
240
+ result = hmset(prepared_value)
192
241
 
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
242
+ # Update expiration in same transaction to ensure atomicity
243
+ self.update_expiration if result && update_expiration
197
244
 
198
- result
245
+ result
246
+ end
199
247
  end
200
248
 
201
249
  # Updates multiple fields atomically in a Database transaction.
@@ -216,20 +264,57 @@ module Familia
216
264
 
217
265
  Familia.trace :BATCH_UPDATE, nil, fields.keys if Familia.debug?
218
266
 
219
- transaction_result = transaction do |conn|
267
+ transaction do |_conn|
268
+ # 1. Update all fields atomically
220
269
  fields.each do |field, value|
221
270
  prepared_value = serialize_value(value)
222
- conn.hset dbkey, field, prepared_value
271
+ hset field, prepared_value
223
272
  # Update instance variable to keep object in sync
224
273
  send("#{field}=", value) if respond_to?("#{field}=")
225
274
  end
275
+
276
+ # 2. Update expiration in same transaction
277
+ self.update_expiration if update_expiration
226
278
  end
279
+ end
280
+
281
+ # Persists only the specified fields to Redis.
282
+ #
283
+ # Saves the current in-memory values of specified fields to Redis without
284
+ # modifying them first. Fields must already be set on the instance.
285
+ #
286
+ # @param field_names [Array<Symbol, String>] Names of fields to persist
287
+ # @param update_expiration [Boolean] Whether to refresh key expiration
288
+ # @return [self] Returns self for method chaining
289
+ #
290
+ # @example Persist only passphrase fields after updating them
291
+ # customer.update_passphrase('secret').save_fields(:passphrase, :passphrase_encryption)
292
+ #
293
+ def save_fields(*field_names, update_expiration: true)
294
+ raise ArgumentError, 'No fields specified' if field_names.empty?
227
295
 
228
- # Update expiration if requested and supported
229
- self.update_expiration(default_expiration: nil) if update_expiration && respond_to?(:update_expiration)
296
+ Familia.trace :SAVE_FIELDS, nil, field_names if Familia.debug?
297
+
298
+ transaction do |_conn|
299
+ # Build hash of field names to serialized values
300
+ fields_hash = {}
301
+ field_names.each do |field|
302
+ field_sym = field.to_sym
303
+ raise ArgumentError, "Unknown field: #{field}" unless respond_to?(field_sym)
304
+
305
+ value = send(field_sym)
306
+ prepared_value = serialize_value(value)
307
+ fields_hash[field] = prepared_value
308
+ end
230
309
 
231
- # Return the MultiResult directly (transaction already returns MultiResult)
232
- transaction_result
310
+ # Set all fields at once using hmset
311
+ hmset(fields_hash)
312
+
313
+ # Update expiration in same transaction
314
+ self.update_expiration if update_expiration
315
+ end
316
+
317
+ self
233
318
  end
234
319
 
235
320
  # Updates the object by applying multiple field values.
@@ -279,22 +364,22 @@ module Familia
279
364
  # @see #delete! The underlying method that performs the key deletion
280
365
  #
281
366
  def destroy!
282
- Familia.trace :DESTROY, dbkey, uri
367
+ Familia.trace :DESTROY!, dbkey, self.class.uri
283
368
 
284
369
  # Execute all deletion operations within a transaction
285
- transaction do |conn|
370
+ transaction do |_conn|
286
371
  # Delete the main object key
287
- conn.del(dbkey)
372
+ delete!
288
373
 
289
374
  # Delete all related fields if present
290
375
  if self.class.relations?
291
376
  Familia.trace :DELETE_RELATED_FIELDS!, nil,
292
377
  "#{self.class} has relations: #{self.class.related_fields.keys}"
293
378
 
294
- self.class.related_fields.each do |name, _definition|
379
+ self.class.related_fields.each_key do |name|
295
380
  obj = send(name)
296
381
  Familia.trace :DELETE_RELATED_FIELD, name, "Deleting related field #{name} (#{obj.dbkey})"
297
- conn.del(obj.dbkey)
382
+ obj.delete!
298
383
  end
299
384
  end
300
385
  end
@@ -318,6 +403,7 @@ module Familia
318
403
  # after clear_fields! if you want to persist the cleared state.
319
404
  #
320
405
  def clear_fields!
406
+ Familia.trace :CLEAR_FIELDS!, dbkey, self.class.uri
321
407
  self.class.field_method_map.each_value { |method_name| send("#{method_name}=", nil) }
322
408
  end
323
409
 
@@ -343,7 +429,7 @@ module Familia
343
429
  # no authoritative source in Valkey storage.
344
430
  #
345
431
  def refresh!
346
- Familia.trace :REFRESH, nil, uri if Familia.debug?
432
+ Familia.trace :REFRESH, nil, self.class.uri if Familia.debug?
347
433
  raise Familia::KeyNotFoundError, dbkey unless dbclient.exists(dbkey)
348
434
 
349
435
  fields = hgetall
@@ -354,7 +440,7 @@ module Familia
354
440
  # their uninitialized state during refresh operations
355
441
  reset_transient_fields!
356
442
 
357
- optimistic_refresh(**fields)
443
+ naive_refresh(**fields)
358
444
  end
359
445
 
360
446
  # Refreshes object state from the DB and returns self for method chaining.
@@ -378,6 +464,11 @@ module Familia
378
464
  self
379
465
  end
380
466
 
467
+ # Convenience methods that forward to the class method of the same name
468
+ def transaction(...) = self.class.transaction(...)
469
+ def pipelined(...) = self.class.pipelined(...)
470
+ def dbclient(...) = self.class.dbclient(...)
471
+
381
472
  private
382
473
 
383
474
  # Reset all transient fields to nil
@@ -402,12 +493,46 @@ module Familia
402
493
  end
403
494
  end
404
495
 
496
+ # Validates that unique index constraints are satisfied before saving
497
+ # This must be called OUTSIDE of transactions to allow reading current values
498
+ #
499
+ # @raise [Familia::RecordExistsError] If a unique index constraint is violated
500
+ # for any class-level unique_index relationships
501
+ #
502
+ # @note Only validates class-level unique indexes (without within: parameter).
503
+ # Instance-scoped indexes (with within:) are validated automatically when
504
+ # calling add_to_*_index methods:
505
+ #
506
+ # @example Instance-scoped indexes need to be called explicitly but when
507
+ # called they will perform the validation automatically:
508
+ # employee.add_to_company_badge_index(company) # raises on duplicate
509
+ #
510
+ # @return [void]
511
+ #
512
+ def guard_unique_indexes!
513
+ return unless self.class.respond_to?(:indexing_relationships)
514
+
515
+ self.class.indexing_relationships.each do |rel|
516
+ # Only validate unique indexes (not multi_index)
517
+ next unless rel.cardinality == :unique
518
+
519
+ # Only validate class-level indexes (skip instance-scoped)
520
+ next if rel.within
521
+
522
+ # Call the validation method if it exists
523
+ validate_method = :"guard_unique_#{rel.index_name}!"
524
+ send(validate_method) if respond_to?(validate_method)
525
+ end
526
+
527
+ nil # Explicit nil return as documented
528
+ end
529
+
405
530
  # Automatically update class-level indexes after save
406
531
  #
407
532
  # Iterates through class-level indexing relationships and calls their
408
533
  # 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.
534
+ # class-level indexes (where within is nil), skipping instance-scoped
535
+ # indexes which require scope context.
411
536
  #
412
537
  # Uses idempotent Redis commands (HSET for unique_index) so repeated calls
413
538
  # are safe and have negligible performance overhead. Note that multi_index
@@ -434,12 +559,12 @@ module Familia
434
559
  return unless self.class.respond_to?(:indexing_relationships)
435
560
 
436
561
  self.class.indexing_relationships.each do |rel|
437
- # Skip instance-scoped indexes (require parent context)
562
+ # Skip instance-scoped indexes (require scope context)
438
563
  # 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
564
+ # the scope instance reference (e.g., employee.add_to_company_badge_index(company))
565
+ if rel.within
441
566
  Familia.ld <<~LOG_MESSAGE
442
- [auto_update_class_indexes] Skipping #{rel.index_name} (requires parent context)
567
+ [auto_update_class_indexes] Skipping #{rel.index_name} (requires scope context)
443
568
  LOG_MESSAGE
444
569
  next
445
570
  end
@@ -449,7 +574,6 @@ module Familia
449
574
  send(add_method) if respond_to?(add_method)
450
575
  end
451
576
  end
452
-
453
577
  end
454
578
  end
455
579
  end
@@ -7,21 +7,20 @@ module Familia
7
7
  module Serialization
8
8
  # Converts the object's persistent fields to a hash for external use.
9
9
  #
10
- # Serializes persistent field values for external consumption (APIs, logs),
11
- # excluding non-loggable fields like encrypted fields for security.
12
- # Only non-nil values are included in the resulting hash.
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
13
  #
14
- # @return [Hash] Hash with field names as keys and serialized values
15
- # safe for external exposure
14
+ # @return [Hash] Hash with field names as string keys and Ruby values
16
15
  #
17
16
  # @example Converting an object to hash format for API response
18
17
  # user = User.new(name: "John", email: "john@example.com", age: 30)
19
18
  # user.to_h
20
- # # => {"name"=>"John", "email"=>"john@example.com", "age"=>"30"}
21
- # # encrypted fields are excluded for security
19
+ # # => {"name"=>"John", "email"=>"john@example.com", "age"=>30}
20
+ # # Note: Returns actual Ruby types, not JSON strings
22
21
  #
23
- # @note Only loggable fields are included for security
24
- # @note Only fields with non-nil values are included
22
+ # @note Only loggable fields are included. Encrypted fields are excluded.
23
+ # @note Nil values are excluded from the returned hash (storage optimization)
25
24
  #
26
25
  def to_h
27
26
  self.class.persistent_fields.each_with_object({}) do |field, hsh|
@@ -30,40 +29,45 @@ module Familia
30
29
  # Security: Skip non-loggable fields (e.g., encrypted fields)
31
30
  next unless field_type.loggable
32
31
 
33
- method_name = field_type.method_name
34
- val = send(method_name)
35
- prepared = serialize_value(val)
36
- Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
32
+ val = send(field_type.method_name)
33
+ Familia.ld " [to_h] field: #{field} val: #{val.class}"
37
34
 
38
- # Only include non-nil values in the hash for Valkey
39
- # Use string key for database compatibility
40
- hsh[field.to_s] = prepared unless prepared.nil?
35
+ # Use string key for external API compatibility
36
+ # Return Ruby values, not JSON-encoded strings
37
+ hsh[field.to_s] = val
41
38
  end
42
39
  end
43
40
 
44
41
  # Converts the object's persistent fields to a hash for database storage.
45
42
  #
46
- # Serializes ALL persistent field values for database storage, including
47
- # encrypted fields. This is used internally by commit_fields and other
48
- # persistence operations.
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).
49
46
  #
50
- # @return [Hash] Hash with field names as keys and serialized values
51
- # ready for database storage
47
+ # @return [Hash] Hash with field names as string keys and JSON-encoded values
52
48
  #
53
- # @note Includes ALL persistent fields, including encrypted fields
54
- # @note Only fields with non-nil values are included for storage efficiency
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
55
57
  #
56
58
  def to_h_for_storage
57
59
  self.class.persistent_fields.each_with_object({}) do |field, hsh|
58
60
  field_type = self.class.field_types[field]
59
- method_name = field_type.method_name
60
- val = send(method_name)
61
+
62
+ val = send(field_type.method_name)
61
63
  prepared = serialize_value(val)
62
- Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
63
64
 
64
- # Only include non-nil values in the hash for Valkey
65
+ if Familia.debug?
66
+ Familia.ld " [to_h_for_storage] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
67
+ end
68
+
65
69
  # Use string key for database compatibility
66
- hsh[field.to_s] = prepared unless prepared.nil?
70
+ hsh[field.to_s] = prepared
67
71
  end
68
72
  end
69
73
 
@@ -84,7 +88,7 @@ module Familia
84
88
  # methods to maintain data consistency across operations.
85
89
  #
86
90
  def to_a
87
- self.class.persistent_fields.filter_map do |field|
91
+ self.class.persistent_fields.map do |field|
88
92
  field_type = self.class.field_types[field]
89
93
 
90
94
  # Security: Skip non-loggable fields (e.g., encrypted fields)
@@ -92,80 +96,97 @@ module Familia
92
96
 
93
97
  method_name = field_type.method_name
94
98
  val = send(method_name)
95
- prepared = serialize_value(val)
96
- Familia.ld " [to_a] field: #{field} method: #{method_name} val: #{val.class} prepared: #{prepared.class}"
97
- prepared
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
98
103
  end
99
104
  end
100
105
 
101
106
  # Serializes a Ruby object for Valkey storage.
102
107
  #
103
- # Converts Ruby objects into the DB-compatible string representations using
104
- # the Familia distinguisher for type coercion. Falls back to JSON serialization
105
- # for complex types (Hash, Array) when the primary distinguisher returns nil.
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.
106
111
  #
107
112
  # The serialization process:
108
- # 1. Attempts conversion using Familia.distinguisher with relaxed type checking
109
- # 2. For Hash/Array types that return nil, tries custom dump_method or Familia::JsonSerializer.dump
110
- # 3. Logs warnings when serialization fails completely
113
+ # 1. ConcealedStrings (encrypted values) extract encrypted_value
114
+ # 2. ALL other types JSON serialization (String, Integer, Boolean, Float, nil, Hash, Array)
111
115
  #
112
116
  # @param val [Object] The Ruby object to serialize for Valkey storage
113
117
  #
114
- # @return [String, nil] The serialized value ready for Valkey storage, or nil
115
- # if serialization failed
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)
116
125
  #
117
- # @example Serializing different data types
118
- # serialize_value("hello") # => "hello"
119
- # serialize_value(42) # => "42"
120
- # serialize_value({name: "John"}) # => '{"name":"John"}'
121
- # serialize_value([1, 2, 3]) # => "[1,2,3]"
126
+ # @note Strings are JSON-encoded to prevent type coercion bugs where
127
+ # string "123" would be indistinguishable from integer 123 in storage
122
128
  #
123
129
  # @note This method integrates with Familia's type system and supports
124
130
  # custom serialization methods when available on the object
125
131
  #
126
- # @see Familia.distinguisher The primary serialization mechanism
132
+ # @see Familia.identifier_extractor For extracting identifiers from Familia objects
127
133
  #
128
134
  def serialize_value(val)
129
135
  # Security: Handle ConcealedString safely - extract encrypted data for storage
130
136
  return val.encrypted_value if val.respond_to?(:encrypted_value)
131
137
 
132
- prepared = Familia.distinguisher(val, strict_values: false)
133
-
134
- # If the distinguisher returns nil, try using the dump_method but only
135
- # use JSON serialization for complex types that need it.
136
- if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
137
- prepared = val.respond_to?(dump_method) ? val.send(dump_method) : Familia::JsonSerializer.dump(val)
138
- end
139
-
140
- # If both the distinguisher and dump_method return nil, log an error
141
- Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}" if prepared.nil?
142
-
143
- prepared
138
+ # ALWAYS write valid JSON for type preservation
139
+ # This includes strings, which get JSON-encoded with wrapping quotes
140
+ Familia::JsonSerializer.dump(val)
144
141
  end
145
142
 
146
- # Converts a Database string value back to its original Ruby type
143
+ # Converts a Redis string value back to its original Ruby type
147
144
  #
148
- # This method attempts to deserialize JSON strings back to their original
149
- # Hash or Array types. Simple string values are returned as-is.
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.
150
148
  #
151
- # @param val [String] The string value from Database to deserialize
152
- # @param symbolize [Boolean] Whether to symbolize hash keys (default: true for compatibility)
153
- # @return [Object] The deserialized value (Hash, Array, or original string)
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
154
153
  #
155
- def deserialize_value(val, symbolize: true)
156
- return val if val.nil? || val == ''
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
+ # Handle Redis::Future objects during transactions
163
+ return val if val.is_a?(Redis::Future)
157
164
 
158
- # Try to parse as JSON first for complex types
159
165
  begin
160
- parsed = Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
161
- # Only return parsed value if it's a complex type (Hash/Array)
162
- # Simple values should remain as strings
163
- return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
166
+ Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
164
167
  rescue Familia::SerializerError
165
- # Not valid JSON, return as-is
168
+ log_deserialization_issue(val, field_name)
169
+ val
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ def log_deserialization_issue(val, field_name)
176
+ context = field_name ? "#{self.class}##{field_name}" : self.class.to_s
177
+ dbkey_info = respond_to?(:dbkey) ? dbkey : 'no dbkey'
178
+
179
+ msg = if looks_like_json?(val)
180
+ "Corrupted JSON in #{context}: #{val.inspect} (#{dbkey_info})"
181
+ else
182
+ "Legacy plain string in #{context}: #{val.inspect} (#{dbkey_info})"
166
183
  end
167
184
 
168
- val
185
+ Familia.le(msg)
186
+ end
187
+
188
+ def looks_like_json?(val)
189
+ val.start_with?('{', '[', '"') || %w[true false null].include?(val)
169
190
  end
170
191
  end
171
192
  end
@@ -11,14 +11,6 @@ module Familia
11
11
  # Provides identifier handling, dbkey generation, and object inspection
12
12
  #
13
13
  module Utils
14
- # def uri
15
- # base_uri = self.class.uri || Familia.uri
16
- # u = base_uri.dup # make a copy to modify safely
17
- # u.logical_database = logical_database if logical_database
18
- # u.key = dbkey
19
- # u
20
- # end
21
-
22
14
  # +suffix+ is the value to be used at the end of the db key
23
15
  # (e.g. `customer:customer_id:scores` would have `scores` as the suffix
24
16
  # and `customer_id` would have been the identifier in that case).