familia 2.0.0.pre15 → 2.0.0.pre17

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 (288) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -2
  3. data/.github/workflows/code-quality.yml +138 -0
  4. data/.github/workflows/code-smells.yml +85 -0
  5. data/.github/workflows/docs.yml +31 -8
  6. data/.gitignore +3 -1
  7. data/.pre-commit-config.yaml +7 -1
  8. data/.reek.yml +98 -0
  9. data/.rubocop.yml +54 -10
  10. data/.talismanrc +9 -0
  11. data/.yardopts +18 -13
  12. data/CHANGELOG.rst +86 -4
  13. data/CLAUDE.md +39 -1
  14. data/Gemfile +6 -5
  15. data/Gemfile.lock +99 -23
  16. data/LICENSE.txt +1 -1
  17. data/README.md +285 -85
  18. data/changelog.d/README.md +2 -2
  19. data/docs/archive/FAMILIA_RELATIONSHIPS.md +22 -22
  20. data/docs/archive/FAMILIA_TECHNICAL.md +42 -42
  21. data/docs/archive/FAMILIA_UPDATE.md +3 -3
  22. data/docs/archive/README.md +3 -2
  23. data/docs/{guides/API-Reference.md → archive/api-reference.md} +87 -101
  24. data/docs/conf.py +29 -0
  25. data/docs/guides/{Field-System-Guide.md → core-field-system.md} +9 -9
  26. data/docs/guides/feature-encrypted-fields.md +785 -0
  27. data/docs/guides/{Expiration-Feature-Guide.md → feature-expiration.md} +11 -2
  28. data/docs/guides/feature-external-identifiers.md +637 -0
  29. data/docs/guides/feature-object-identifiers.md +435 -0
  30. data/docs/guides/{Quantization-Feature-Guide.md → feature-quantization.md} +94 -29
  31. data/docs/guides/feature-relationships-methods.md +684 -0
  32. data/docs/guides/feature-relationships.md +200 -0
  33. data/docs/guides/{Features-System-Developer-Guide.md → feature-system-devs.md} +4 -4
  34. data/docs/guides/{Feature-System-Guide.md → feature-system.md} +5 -5
  35. data/docs/guides/{Transient-Fields-Guide.md → feature-transient-fields.md} +2 -2
  36. data/docs/guides/{Implementation-Guide.md → implementation.md} +3 -3
  37. data/docs/guides/index.md +176 -0
  38. data/docs/guides/{Security-Model.md → security-model.md} +1 -1
  39. data/docs/migrating/v2.0.0-pre.md +1 -1
  40. data/docs/migrating/v2.0.0-pre11.md +2 -2
  41. data/docs/migrating/v2.0.0-pre12.md +2 -2
  42. data/docs/migrating/v2.0.0-pre5.md +33 -12
  43. data/docs/migrating/v2.0.0-pre6.md +2 -2
  44. data/docs/migrating/v2.0.0-pre7.md +8 -8
  45. data/docs/overview.md +624 -20
  46. data/docs/reference/api-technical.md +1365 -0
  47. data/examples/autoloader/mega_customer/features/deprecated_fields.rb +7 -0
  48. data/examples/autoloader/mega_customer/safe_dump_fields.rb +1 -1
  49. data/examples/autoloader/mega_customer.rb +3 -1
  50. data/examples/encrypted_fields.rb +378 -0
  51. data/examples/json_usage_patterns.rb +144 -0
  52. data/examples/relationships.rb +13 -13
  53. data/examples/safe_dump.rb +7 -7
  54. data/examples/single_connection_transaction_confusions.rb +379 -0
  55. data/lib/familia/base.rb +51 -10
  56. data/lib/familia/connection/handlers.rb +223 -0
  57. data/lib/familia/connection/individual_command_proxy.rb +64 -0
  58. data/lib/familia/connection/middleware.rb +75 -0
  59. data/lib/familia/connection/operation_core.rb +93 -0
  60. data/lib/familia/connection/operations.rb +277 -0
  61. data/lib/familia/connection/pipeline_core.rb +87 -0
  62. data/lib/familia/connection/transaction_core.rb +100 -0
  63. data/lib/familia/connection.rb +60 -186
  64. data/lib/familia/data_type/class_methods.rb +63 -0
  65. data/lib/familia/data_type/commands.rb +53 -51
  66. data/lib/familia/data_type/connection.rb +83 -0
  67. data/lib/familia/data_type/serialization.rb +108 -107
  68. data/lib/familia/data_type/settings.rb +96 -0
  69. data/lib/familia/data_type/types/counter.rb +1 -1
  70. data/lib/familia/data_type/types/hashkey.rb +15 -11
  71. data/lib/familia/data_type/types/{list.rb → listkey.rb} +13 -5
  72. data/lib/familia/data_type/types/lock.rb +3 -2
  73. data/lib/familia/data_type/types/sorted_set.rb +128 -14
  74. data/lib/familia/data_type/types/{string.rb → stringkey.rb} +7 -9
  75. data/lib/familia/data_type/types/unsorted_set.rb +20 -27
  76. data/lib/familia/data_type.rb +12 -171
  77. data/lib/familia/distinguisher.rb +85 -0
  78. data/lib/familia/encryption/encrypted_data.rb +15 -24
  79. data/lib/familia/encryption/manager.rb +6 -4
  80. data/lib/familia/encryption/providers/aes_gcm_provider.rb +1 -1
  81. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +7 -9
  82. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +4 -5
  83. data/lib/familia/encryption/request_cache.rb +7 -7
  84. data/lib/familia/encryption.rb +2 -3
  85. data/lib/familia/errors.rb +9 -3
  86. data/lib/familia/features/autoloader.rb +30 -12
  87. data/lib/familia/features/encrypted_fields/concealed_string.rb +3 -4
  88. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +13 -14
  89. data/lib/familia/features/encrypted_fields.rb +71 -66
  90. data/lib/familia/features/expiration/extensions.rb +1 -1
  91. data/lib/familia/features/expiration.rb +31 -26
  92. data/lib/familia/features/external_identifier.rb +57 -19
  93. data/lib/familia/features/object_identifier.rb +134 -25
  94. data/lib/familia/features/quantization.rb +16 -21
  95. data/lib/familia/features/relationships/README.md +97 -0
  96. data/lib/familia/features/relationships/collection_operations.rb +104 -0
  97. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +202 -0
  98. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +306 -0
  99. data/lib/familia/features/relationships/indexing.rb +182 -256
  100. data/lib/familia/features/relationships/indexing_relationship.rb +35 -0
  101. data/lib/familia/features/relationships/participation/participant_methods.rb +164 -0
  102. data/lib/familia/features/relationships/participation/target_methods.rb +225 -0
  103. data/lib/familia/features/relationships/participation.rb +656 -0
  104. data/lib/familia/features/relationships/participation_relationship.rb +31 -0
  105. data/lib/familia/features/relationships/score_encoding.rb +20 -20
  106. data/lib/familia/features/relationships.rb +65 -266
  107. data/lib/familia/features/safe_dump.rb +127 -130
  108. data/lib/familia/features/transient_fields/redacted_string.rb +6 -6
  109. data/lib/familia/features/transient_fields/transient_field_type.rb +5 -5
  110. data/lib/familia/features/transient_fields.rb +10 -7
  111. data/lib/familia/features.rb +10 -14
  112. data/lib/familia/field_type.rb +6 -4
  113. data/lib/familia/horreum/connection.rb +297 -0
  114. data/lib/familia/horreum/{core/database_commands.rb → database_commands.rb} +27 -17
  115. data/lib/familia/horreum/{subclass/definition.rb → definition.rb} +139 -74
  116. data/lib/familia/horreum/{subclass/management.rb → management.rb} +73 -27
  117. data/lib/familia/horreum/{core/serialization.rb → persistence.rb} +108 -185
  118. data/lib/familia/horreum/{subclass/related_fields_management.rb → related_fields.rb} +104 -23
  119. data/lib/familia/horreum/serialization.rb +172 -0
  120. data/lib/familia/horreum/{shared/settings.rb → settings.rb} +2 -1
  121. data/lib/familia/horreum/{core/utils.rb → utils.rb} +2 -1
  122. data/lib/familia/horreum.rb +222 -119
  123. data/lib/familia/json_serializer.rb +0 -1
  124. data/lib/familia/logging.rb +11 -114
  125. data/lib/familia/refinements/dear_json.rb +122 -0
  126. data/lib/familia/refinements/logger_trace.rb +20 -17
  127. data/lib/familia/refinements/stylize_words.rb +65 -0
  128. data/lib/familia/refinements/time_literals.rb +60 -52
  129. data/lib/familia/refinements.rb +2 -1
  130. data/lib/familia/secure_identifier.rb +60 -28
  131. data/lib/familia/settings.rb +83 -7
  132. data/lib/familia/utils.rb +5 -87
  133. data/lib/familia/verifiable_identifier.rb +4 -4
  134. data/lib/familia/version.rb +1 -1
  135. data/lib/familia.rb +72 -14
  136. data/lib/middleware/database_middleware.rb +56 -14
  137. data/lib/{familia/multi_result.rb → multi_result.rb} +23 -16
  138. data/try/configuration/scenarios_try.rb +2 -2
  139. data/try/connection/fiber_context_preservation_try.rb +250 -0
  140. data/try/connection/handler_constraints_try.rb +59 -0
  141. data/try/connection/operation_mode_guards_try.rb +208 -0
  142. data/try/connection/pipeline_fallback_integration_try.rb +128 -0
  143. data/try/connection/responsibility_chain_tracking_try.rb +72 -0
  144. data/try/connection/transaction_fallback_integration_try.rb +288 -0
  145. data/try/connection/transaction_mode_permissive_try.rb +153 -0
  146. data/try/connection/transaction_mode_strict_try.rb +98 -0
  147. data/try/connection/transaction_mode_warn_try.rb +131 -0
  148. data/try/connection/transaction_modes_try.rb +249 -0
  149. data/try/core/autoloader_try.rb +120 -2
  150. data/try/core/connection_try.rb +10 -10
  151. data/try/core/conventional_inheritance_try.rb +130 -0
  152. data/try/core/create_method_try.rb +15 -23
  153. data/try/core/database_consistency_try.rb +11 -10
  154. data/try/core/errors_try.rb +11 -14
  155. data/try/core/familia_extended_try.rb +2 -2
  156. data/try/core/familia_members_methods_try.rb +76 -0
  157. data/try/core/familia_try.rb +1 -1
  158. data/try/core/isolated_dbclient_try.rb +165 -0
  159. data/try/core/middleware_try.rb +16 -16
  160. data/try/core/persistence_operations_try.rb +4 -4
  161. data/try/core/pools_try.rb +42 -26
  162. data/try/core/secure_identifier_try.rb +28 -24
  163. data/try/core/time_utils_try.rb +10 -10
  164. data/try/core/tools_try.rb +3 -3
  165. data/try/core/utils_try.rb +2 -2
  166. data/try/data_types/boolean_try.rb +4 -4
  167. data/try/data_types/datatype_base_try.rb +0 -2
  168. data/try/data_types/list_try.rb +10 -10
  169. data/try/data_types/sorted_set_try.rb +5 -5
  170. data/try/data_types/sorted_set_zadd_options_try.rb +625 -0
  171. data/try/data_types/string_try.rb +12 -12
  172. data/try/data_types/unsortedset_try.rb +33 -0
  173. data/try/debugging/cache_behavior_tracer.rb +7 -7
  174. data/try/debugging/debug_aad_process.rb +1 -1
  175. data/try/debugging/debug_concealed_internal.rb +1 -1
  176. data/try/debugging/debug_cross_context.rb +1 -1
  177. data/try/debugging/debug_fresh_cross_context.rb +1 -1
  178. data/try/debugging/encryption_method_tracer.rb +10 -10
  179. data/try/edge_cases/hash_symbolization_try.rb +1 -1
  180. data/try/edge_cases/ttl_side_effects_try.rb +1 -1
  181. data/try/encryption/config_persistence_try.rb +2 -2
  182. data/try/encryption/encryption_core_try.rb +19 -19
  183. data/try/encryption/instance_variable_scope_try.rb +1 -1
  184. data/try/encryption/module_loading_try.rb +2 -2
  185. data/try/encryption/providers/aes_gcm_provider_try.rb +1 -1
  186. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +1 -1
  187. data/try/encryption/secure_memory_handling_try.rb +1 -1
  188. data/try/features/encrypted_fields/concealed_string_core_try.rb +11 -7
  189. data/try/features/encrypted_fields/encrypted_fields_core_try.rb +1 -1
  190. data/try/features/encrypted_fields/encrypted_fields_integration_try.rb +3 -3
  191. data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +10 -10
  192. data/try/features/encrypted_fields/encrypted_fields_security_try.rb +14 -14
  193. data/try/features/encrypted_fields/error_conditions_try.rb +7 -7
  194. data/try/features/encrypted_fields/fresh_key_try.rb +1 -1
  195. data/try/features/encrypted_fields/nonce_uniqueness_try.rb +1 -1
  196. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +7 -7
  197. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +13 -20
  198. data/try/features/external_identifier/external_identifier_try.rb +1 -1
  199. data/try/features/feature_dependencies_try.rb +3 -3
  200. data/try/features/field_groups_try.rb +244 -0
  201. data/try/features/object_identifier/object_identifier_integration_try.rb +28 -34
  202. data/try/features/object_identifier/object_identifier_try.rb +10 -0
  203. data/try/features/quantization/quantization_try.rb +1 -1
  204. data/try/features/relationships/indexing_commands_verification_try.rb +136 -0
  205. data/try/features/relationships/indexing_try.rb +443 -0
  206. data/try/features/relationships/participation_commands_verification_spec.rb +102 -0
  207. data/try/features/relationships/participation_commands_verification_try.rb +105 -0
  208. data/try/features/relationships/participation_performance_improvements_try.rb +124 -0
  209. data/try/features/relationships/participation_reverse_index_try.rb +196 -0
  210. data/try/features/relationships/relationships_api_changes_try.rb +72 -71
  211. data/try/features/relationships/relationships_edge_cases_try.rb +15 -18
  212. data/try/features/relationships/relationships_performance_minimal_try.rb +2 -2
  213. data/try/features/relationships/relationships_performance_simple_try.rb +8 -8
  214. data/try/features/relationships/relationships_performance_try.rb +20 -20
  215. data/try/features/relationships/relationships_try.rb +27 -38
  216. data/try/features/safe_dump/safe_dump_advanced_try.rb +2 -2
  217. data/try/features/transient_fields/refresh_reset_try.rb +3 -1
  218. data/try/features/transient_fields/simple_refresh_test.rb +1 -1
  219. data/try/helpers/test_cleanup.rb +86 -0
  220. data/try/helpers/test_helpers.rb +6 -7
  221. data/try/horreum/auto_indexing_on_save_try.rb +212 -0
  222. data/try/horreum/base_try.rb +3 -2
  223. data/try/horreum/commands_try.rb +3 -1
  224. data/try/horreum/defensive_initialization_try.rb +86 -0
  225. data/try/horreum/destroy_related_fields_cleanup_try.rb +332 -0
  226. data/try/horreum/initialization_try.rb +11 -7
  227. data/try/horreum/relations_try.rb +21 -13
  228. data/try/horreum/serialization_try.rb +12 -11
  229. data/try/horreum/settings_try.rb +2 -0
  230. data/try/integration/cross_component_try.rb +3 -3
  231. data/try/memory/memory_basic_test.rb +1 -1
  232. data/try/memory/memory_docker_ruby_dump.sh +2 -2
  233. data/try/models/customer_safe_dump_try.rb +1 -1
  234. data/try/models/customer_try.rb +13 -15
  235. data/try/models/datatype_base_try.rb +3 -3
  236. data/try/models/familia_object_try.rb +9 -8
  237. data/try/performance/benchmarks_try.rb +2 -2
  238. data/try/prototypes/atomic_saves_v1_context_proxy.rb +2 -2
  239. data/try/prototypes/atomic_saves_v3_connection_pool.rb +3 -3
  240. data/try/prototypes/atomic_saves_v4.rb +1 -1
  241. data/try/prototypes/lib/atomic_saves_v2_connection_switching_helpers.rb +4 -4
  242. data/try/prototypes/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -4
  243. data/try/prototypes/pooling/lib/atomic_saves_v3_connection_pool_helpers.rb +4 -4
  244. data/try/prototypes/pooling/lib/connection_pool_metrics.rb +5 -5
  245. data/try/prototypes/pooling/lib/connection_pool_stress_test.rb +26 -26
  246. data/try/prototypes/pooling/lib/connection_pool_threading_models.rb +7 -7
  247. data/try/prototypes/pooling/lib/visualize_stress_results.rb +1 -1
  248. data/try/prototypes/pooling/pool_siege.rb +11 -11
  249. data/try/prototypes/pooling/run_stress_tests.rb +7 -7
  250. data/try/refinements/dear_json_array_methods_try.rb +53 -0
  251. data/try/refinements/dear_json_hash_methods_try.rb +54 -0
  252. data/try/refinements/logger_trace_methods_try.rb +44 -0
  253. data/try/refinements/time_literals_numeric_methods_try.rb +141 -0
  254. data/try/refinements/time_literals_string_methods_try.rb +80 -0
  255. data/try/valkey.conf +26 -0
  256. metadata +92 -52
  257. data/.rubocop_todo.yml +0 -208
  258. data/docs/connection_pooling.md +0 -192
  259. data/docs/guides/Connection-Pooling-Guide.md +0 -437
  260. data/docs/guides/Encrypted-Fields-Overview.md +0 -101
  261. data/docs/guides/Feature-System-Autoloading.md +0 -198
  262. data/docs/guides/Home.md +0 -116
  263. data/docs/guides/Relationships-Guide.md +0 -737
  264. data/docs/guides/relationships-methods.md +0 -266
  265. data/docs/reference/auditing_database_commands.rb +0 -228
  266. data/examples/permissions.rb +0 -240
  267. data/lib/familia/features/relationships/cascading.rb +0 -437
  268. data/lib/familia/features/relationships/membership.rb +0 -497
  269. data/lib/familia/features/relationships/permission_management.rb +0 -264
  270. data/lib/familia/features/relationships/querying.rb +0 -615
  271. data/lib/familia/features/relationships/redis_operations.rb +0 -274
  272. data/lib/familia/features/relationships/tracking.rb +0 -418
  273. data/lib/familia/horreum/core/connection.rb +0 -73
  274. data/lib/familia/horreum/core.rb +0 -21
  275. data/lib/familia/refinements/snake_case.rb +0 -40
  276. data/lib/familia/validation/command_recorder.rb +0 -336
  277. data/lib/familia/validation/expectations.rb +0 -519
  278. data/lib/familia/validation/validation_helpers.rb +0 -443
  279. data/lib/familia/validation/validator.rb +0 -412
  280. data/lib/familia/validation.rb +0 -140
  281. data/try/data_types/set_try.rb +0 -33
  282. data/try/features/relationships/categorical_permissions_try.rb +0 -515
  283. data/try/features/safe_dump/module_based_extensions_try.rb +0 -100
  284. data/try/features/safe_dump/safe_dump_autoloading_try.rb +0 -107
  285. data/try/validation/atomic_operations_try.rb.disabled +0 -320
  286. data/try/validation/command_validation_try.rb.disabled +0 -207
  287. data/try/validation/performance_validation_try.rb.disabled +0 -324
  288. data/try/validation/real_world_scenarios_try.rb.disabled +0 -390
@@ -8,7 +8,7 @@ module Familia
8
8
  #
9
9
  # Object identifiers are:
10
10
  # - Unique across the system
11
- # - Persistent (stored in Redis/Valkey)
11
+ # - Persistent (stored in Valkey/Redis)
12
12
  # - Lazily generated (only when first accessed)
13
13
  # - Configurable (multiple generation strategies available)
14
14
  # - Preserved during initialization (existing IDs never regenerated)
@@ -50,7 +50,7 @@ module Familia
50
50
  #
51
51
  # # Custom generation strategy
52
52
  # class TimestampedItem < Familia::Horreum
53
- # feature :object_identifier, generator: -> { "item_#{Time.now.to_i}_#{SecureRandom.hex(4)}" }
53
+ # feature :object_identifier, generator: -> { "item_#{Familia.now.to_i}_#{SecureRandom.hex(4)}" }
54
54
  # field :data
55
55
  # end
56
56
  #
@@ -60,9 +60,9 @@ module Familia
60
60
  # Data Integrity Guarantees:
61
61
  #
62
62
  # The feature preserves the object identifier passed during initialization,
63
- # ensuring that existing objects loaded from Redis maintain their IDs:
63
+ # ensuring that existing objects loaded from Valkey/Redis maintain their IDs:
64
64
  #
65
- # # Loading existing object from Redis preserves ID
65
+ # # Loading existing object from Valkey/Redis preserves ID
66
66
  # existing = User.new(objid: 'existing-uuid-value', email: 'existing@example.com')
67
67
  # existing.objid # => "existing-uuid-value" (preserved, not regenerated)
68
68
  #
@@ -71,7 +71,7 @@ module Familia
71
71
  # - Lazy Generation: IDs generated only when first accessed
72
72
  # - Thread-Safe: Generator strategy configured once during initialization
73
73
  # - Memory Efficient: No unnecessary ID generation for unused objects
74
- # - Redis Efficient: Only persists non-nil values to conserve memory
74
+ # - Valkey/Redis Efficient: Only persists non-nil values to conserve memory
75
75
  #
76
76
  # Security Considerations:
77
77
  #
@@ -86,12 +86,22 @@ module Familia
86
86
  DEFAULT_GENERATOR = :uuid_v7
87
87
 
88
88
  def self.included(base)
89
- Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
90
- base.extend ClassMethods
89
+ Familia.trace :LOADED, self, base if Familia.debug?
90
+ base.extend ModelClassMethods
91
+ base.include ModelInstanceMethods
91
92
 
92
93
  # Ensure default generator is set in feature options
93
94
  base.add_feature_options(:object_identifier, generator: DEFAULT_GENERATOR)
94
95
 
96
+ # Add class-level mapping for objid -> id lookups.
97
+ #
98
+ # If the model uses objid as it's primary key, this mapping will be
99
+ # redundant to the builtin functionality of horreum clases, that
100
+ # automatically populate ModelClass.instances sorted set. However,
101
+ # if the model uses any other field as primary key, this mapping
102
+ # is necessary to lookup objects by their objid.
103
+ base.class_hashkey :objid_lookup
104
+
95
105
  # Register the objid field using a simple custom field type
96
106
  base.register_field_type(ObjectIdentifierFieldType.new(:objid, as: :objid, fast_method: false))
97
107
  end
@@ -101,7 +111,7 @@ module Familia
101
111
  # Object identifier fields automatically generate unique identifiers when first
102
112
  # accessed if not already set. The generation strategy is configurable via
103
113
  # feature options. These fields preserve any values set during initialization
104
- # to ensure data integrity when loading existing objects from Redis.
114
+ # to ensure data integrity when loading existing objects from the database.
105
115
  #
106
116
  # The field type tracks the generator used for each objid to provide provenance
107
117
  # information for security-sensitive operations like external identifier generation.
@@ -166,7 +176,7 @@ module Familia
166
176
  # Override setter to preserve values during initialization
167
177
  #
168
178
  # This ensures that values passed during object initialization
169
- # (e.g., when loading from Redis) are preserved and not overwritten
179
+ # (e.g., when loading from Valkey/Redis) are preserved and not overwritten
170
180
  # by the lazy generation logic.
171
181
  #
172
182
  # @param klass [Class] The class to define the method on
@@ -177,13 +187,20 @@ module Familia
177
187
 
178
188
  handle_method_conflict(klass, :"#{method_name}=") do
179
189
  klass.define_method :"#{method_name}=" do |value|
190
+ # Remove old mapping if objid is changing
191
+ old_value = instance_variable_get(:"@#{field_name}")
192
+ if old_value && old_value != value
193
+ Familia.logger.info("Removing objid mapping for #{old_value}")
194
+ self.class.objid_lookup.remove_field(old_value)
195
+ end
196
+
180
197
  instance_variable_set(:"@#{field_name}", value)
181
198
 
182
- # When setting objid from external source (e.g., loading from Redis),
183
- # we cannot determine the original generator, so we clear the provenance
184
- # tracking to indicate unknown origin. This prevents false assumptions
185
- # about the security properties of externally-provided identifiers.
186
- instance_variable_set(:"@#{field_name}_generator_used", nil)
199
+ # When setting objid from external source (e.g., loading from Valkey/Redis),
200
+ # infer the generator type from the format to restore provenance tracking.
201
+ # This allows features like ExternalIdentifier to work correctly on loaded objects.
202
+ inferred_generator = infer_objid_generator(value)
203
+ instance_variable_set(:"@#{field_name}_generator_used", inferred_generator)
187
204
  end
188
205
  end
189
206
  end
@@ -205,7 +222,7 @@ module Familia
205
222
  end
206
223
  end
207
224
 
208
- module ClassMethods
225
+ module ModelClassMethods
209
226
  # Generate a new object identifier using the configured strategy
210
227
  #
211
228
  # @return [String] A new unique identifier
@@ -220,7 +237,7 @@ module Familia
220
237
  when :uuid_v4
221
238
  SecureRandom.uuid_v4
222
239
  when :hex
223
- Familia.generate_hex_id
240
+ Familia.generate_id(16)
224
241
  when Proc
225
242
  generator.call
226
243
  else
@@ -243,18 +260,73 @@ module Familia
243
260
 
244
261
  if Familia.debug?
245
262
  reference = caller(1..1).first
246
- Familia.trace :FIND_BY_OBJID, Familia.dbclient, objid, reference
263
+ Familia.trace :FIND_BY_OBJID, nil, objid, reference
247
264
  end
248
265
 
249
- # Use the object identifier as the key for lookup
250
- # This is a simple stub implementation - would need more sophisticated
251
- # search logic in a real application
252
- find_by_id(objid)
266
+ # Look up the primary ID from the external ID mapping
267
+ primary_id = objid_lookup[objid]
268
+
269
+ # If there is no mapping for this instance's objid, perhaps
270
+ # the object dbkey is already using the objid.
271
+ primary_id = objid if primary_id.nil?
272
+
273
+ find_by_id(primary_id)
253
274
  rescue Familia::NotFound
275
+ # If the object was deleted but mapping wasn't cleaned up
276
+ # we could autoclean here, as long as we log it.
277
+ # objid_lookup.remove_field(objid)
254
278
  nil
255
279
  end
256
280
  end
257
281
 
282
+ # Instance methods for object identifier management
283
+ module ModelInstanceMethods
284
+ # Override save to update objid_lookup mapping
285
+ #
286
+ # This ensures the objid_lookup index is populated during save operations
287
+ # rather than during object initialization, preventing unwanted database
288
+ # writes when calling .new()
289
+ #
290
+ # @param update_expiration [Boolean] Whether to update key expiration
291
+ # @return [Boolean] True if save was successful
292
+ #
293
+ def save(update_expiration: true)
294
+ result = super
295
+
296
+ # Update objid_lookup mapping after successful save
297
+ if result && respond_to?(:objid) && respond_to?(:identifier)
298
+ current_objid = objid # Triggers lazy generation if needed
299
+ if current_objid && identifier
300
+ self.class.objid_lookup[current_objid] = identifier
301
+ end
302
+ end
303
+
304
+ result
305
+ end
306
+
307
+ # Override save_if_not_exists to update objid_lookup mapping
308
+ #
309
+ # This ensures the objid_lookup index is populated during create operations
310
+ # which use save_if_not_exists instead of save.
311
+ #
312
+ # @param update_expiration [Boolean] Whether to update key expiration
313
+ # @return [Boolean] True if save was successful
314
+ #
315
+ def save_if_not_exists(update_expiration: true)
316
+ result = super
317
+
318
+ # Update objid_lookup mapping after successful save
319
+ if result && respond_to?(:objid) && respond_to?(:identifier)
320
+ current_objid = objid # Triggers lazy generation if needed
321
+ if current_objid && identifier
322
+ self.class.objid_lookup[current_objid] = identifier
323
+ end
324
+ end
325
+
326
+ result
327
+ end
328
+ end
329
+
258
330
  # Instance method for generating object identifier using configured strategy
259
331
  #
260
332
  # This method is called by the ObjectIdentifierFieldType when lazy generation
@@ -275,14 +347,52 @@ module Familia
275
347
  objid
276
348
  end
277
349
 
278
- # Full-length alias setter for objid
350
+ # Infers the generator type (:uuid_v7, :uuid_v4, :hex) from the format of an objid string.
279
351
  #
280
- # @param value [String] The object identifier to set
352
+ # This method analyzes the objid format to restore provenance tracking when loading
353
+ # objects from Redis, allowing dependent features like ExternalIdentifier to work correctly.
281
354
  #
355
+ # @param objid_value [String] The objid string to analyze
356
+ # @return [Symbol, nil] The inferred generator type or nil if unknown
357
+ def infer_objid_generator(objid_value)
358
+ return nil if objid_value.nil? || objid_value.to_s.empty?
359
+
360
+ objid_str = objid_value.to_s
361
+
362
+ # UUID format: xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)
363
+ # where V is the version nibble at position 14
364
+ if objid_str.length == 36 && objid_str[8] == '-' && objid_str[13] == '-' && objid_str[18] == '-' && objid_str[23] == '-'
365
+ version_char = objid_str[14]
366
+ case version_char
367
+ when '7'
368
+ :uuid_v7
369
+ when '4'
370
+ :uuid_v4
371
+ else
372
+ nil # Unknown UUID version
373
+ end
374
+ # Hex format: pure hexadecimal without hyphens (32 or 64 chars typically)
375
+ elsif objid_str.match?(/\A[0-9a-fA-F]+\z/)
376
+ :hex
377
+ else
378
+ nil # Unknown format
379
+ end
380
+ end
381
+ private :infer_objid_generator
382
+
282
383
  def object_identifier=(value)
283
384
  self.objid = value
284
385
  end
285
386
 
387
+ def destroy!
388
+ # Clean up objid mapping when object is destroyed
389
+ current_objid = instance_variable_get(:@objid)
390
+
391
+ self.class.objid_lookup.remove_field(current_objid) if current_objid
392
+
393
+ super if defined?(super)
394
+ end
395
+
286
396
  # Initialize object identifier configuration
287
397
  #
288
398
  # Called during object initialization to set up the ID generation strategy.
@@ -300,9 +410,8 @@ module Familia
300
410
 
301
411
  options = self.class.feature_options(:object_identifier)
302
412
  generator = options[:generator] || DEFAULT_GENERATOR
303
- Familia.trace :OBJID_INIT, dbclient, "Generator strategy: #{generator}", caller(1..1)
413
+ Familia.trace :OBJID_INIT, nil, "Generator strategy: #{generator}"
304
414
  end
305
-
306
415
  end
307
416
  end
308
417
  end
@@ -106,7 +106,7 @@ module Familia
106
106
  # activity.save
107
107
  # end
108
108
  #
109
- # def self.activity_for_hour(time = Time.now)
109
+ # def self.activity_for_hour(time = Familia.now)
110
110
  # bucket_id = "activity:#{qstamp(1.hour, time: time, pattern: '%Y%m%d%H')}"
111
111
  # find(bucket_id)
112
112
  # end
@@ -129,7 +129,7 @@ module Familia
129
129
  # interval: interval.to_i)
130
130
  # end
131
131
  #
132
- # metric.data_points.add(timestamp, value)
132
+ # metric.data_points.add(value, timestamp)
133
133
  # metric.timestamp = timestamp
134
134
  # metric.value = value
135
135
  # metric.save
@@ -175,13 +175,13 @@ module Familia
175
175
  #
176
176
  # def self.utc_hourly_key(metric_name)
177
177
  # # Always use UTC for consistent global buckets
178
- # timestamp = qstamp(1.hour, time: Time.now.utc, pattern: '%Y%m%d%H')
178
+ # timestamp = qstamp(1.hour, time: Familia.now, pattern: '%Y%m%d%H')
179
179
  # "global:#{metric_name}:#{timestamp}"
180
180
  # end
181
181
  #
182
182
  # def self.local_daily_key(metric_name, timezone = 'America/New_York')
183
183
  # # Use local timezone for region-specific buckets
184
- # local_time = Time.now.in_time_zone(timezone)
184
+ # local_time = Familia.now.in_time_zone(timezone)
185
185
  # timestamp = qstamp(1.day, time: local_time, pattern: '%Y%m%d')
186
186
  # "#{timezone.gsub('/', '_')}:#{metric_name}:#{timestamp}"
187
187
  # end
@@ -194,7 +194,7 @@ module Familia
194
194
  #
195
195
  # # Cache quantized timestamps to avoid repeated calculations
196
196
  # def self.cached_qstamp(quantum, pattern: nil, time: nil)
197
- # cache_key = "qstamp:#{quantum}:#{pattern}:#{(time || Time.now).to_i / quantum}"
197
+ # cache_key = "qstamp:#{quantum}:#{pattern}:#{(time || Familia.now).to_i / quantum}"
198
198
  # Rails.cache.fetch(cache_key, expires_in: quantum) do
199
199
  # qstamp(quantum, pattern: pattern, time: time)
200
200
  # end
@@ -245,19 +245,18 @@ module Familia
245
245
  # NoDefault.qstamp() # Uses 10.minutes as fallback quantum
246
246
  #
247
247
  module Quantization
248
-
249
248
  Familia::Base.add_feature self, :quantization
250
249
 
251
250
  using Familia::Refinements::TimeLiterals
252
251
 
253
252
  def self.included(base)
254
- Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
255
- base.extend ClassMethods
253
+ Familia.trace :LOADED, self, base if Familia.debug?
254
+ base.extend ModelClassMethods
256
255
  end
257
256
 
258
- # Familia::Quantization::ClassMethods
257
+ # Familia::Quantization::ModelClassMethods
259
258
  #
260
- module ClassMethods
259
+ module ModelClassMethods
261
260
  # Generates a quantized timestamp based on the given parameters
262
261
  #
263
262
  # This method rounds the current time to the nearest quantum and optionally
@@ -286,9 +285,7 @@ module Familia
286
285
  #
287
286
  def qstamp(quantum = nil, pattern: nil, time: nil)
288
287
  # Handle array input format: [quantum, pattern]
289
- if quantum.is_a?(Array)
290
- quantum, pattern = quantum
291
- end
288
+ quantum, pattern = quantum if quantum.is_a?(Array)
292
289
 
293
290
  # Use default quantum if none specified
294
291
  # Priority: provided quantum > class default_expiration > 10.minutes fallback
@@ -323,11 +320,11 @@ module Familia
323
320
  end_bucket = qstamp(quantum, time: end_time)
324
321
 
325
322
  while current <= end_bucket
326
- if pattern
327
- timestamps << Time.at(current).strftime(pattern)
328
- else
329
- timestamps << current
330
- end
323
+ timestamps << if pattern
324
+ Time.at(current).strftime(pattern)
325
+ else
326
+ current
327
+ end
331
328
  current += quantum
332
329
  end
333
330
 
@@ -352,7 +349,7 @@ module Familia
352
349
  bucket_start = qstamp(quantum, time: Time.at(bucket_time))
353
350
  bucket_end = bucket_start + quantum - 1
354
351
 
355
- timestamp >= bucket_start && timestamp <= bucket_end
352
+ timestamp.between?(bucket_start, bucket_end)
356
353
  end
357
354
  end
358
355
 
@@ -397,8 +394,6 @@ module Familia
397
394
  base_id = respond_to?(:identifier) ? identifier : object_id
398
395
  "#{base_id}#{separator}#{timestamp}"
399
396
  end
400
-
401
- extend ClassMethods
402
397
  end
403
398
  end
404
399
  end
@@ -0,0 +1,97 @@
1
+ <!--lib/familia/features/relationships/README.md-->
2
+
3
+ ## Core Modules
4
+
5
+ **relationships.rb** - Main orchestrator that unifies all relationship functionality into a single feature, providing the public API and coordinating between all submodules.
6
+
7
+ **indexing.rb** - O(1) lookup capability via Valkey/Redis hashes and sets. Enables fast field-based searches when parent-scoped (within: ParentClass). Creates instance methods on parent class for scoped lookups.
8
+
9
+ **participation.rb** - Multi-presence management where objects can exist in multiple collections simultaneously with score-encoded metadata (timestamps, permissions, etc.). All add/remove operations use transactions for atomicity.
10
+
11
+ ## Quick API Guide
12
+
13
+ **participates_in** - Collection membership ("this object belongs in that collection")
14
+ ```ruby
15
+ participates_in Organization, :members, score: :joined_at, bidirectional: true
16
+ # Creates: org.members, org.add_member(), customer.add_to_organization_members()
17
+ ```
18
+
19
+ **unique_index** - Fast unique lookups ("find object by unique field value")
20
+ ```ruby
21
+ unique_index :email, :email_index, within: Organization # Scoped: org.find_by_email()
22
+ ```
23
+
24
+ **multi_index** - Fast multi-value lookups ("find all objects by field value")
25
+ ```ruby
26
+ multi_index :department, :dept_index, within: Organization
27
+ # Creates: org.sample_from_department(), org.find_all_by_department()
28
+ ```
29
+
30
+ ## Key Philosophy
31
+
32
+ The entire system embraces "where does this appear?" rather than "who owns this?" - enabling objects to exist in multiple contexts simultaneously while maintaining fast lookups and atomic operations.
33
+
34
+ ## When to Use Which
35
+
36
+ <details>
37
+ <summary>📋 participates_in vs indexing - Decision Guide</summary>
38
+
39
+ ### participates_in - Collection Membership
40
+ - **Purpose**: "This object belongs in that collection"
41
+ - **Storage**: SortedSet/Set/List of object IDs with optional scores
42
+ - **Use for**: Membership relationships, ordered lists, scored collections
43
+ - **Example**: Customers in an Organization, Tasks in a Project
44
+ - **Atomicity**: Transactions for all operations (collection + reverse index)
45
+
46
+ ```ruby
47
+ participates_in Organization, :members, score: :joined_at
48
+ # Creates: org.members (SortedSet), org.add_member(), customer.add_to_organization_members()
49
+ ```
50
+
51
+ ### unique_index - Fast Unique Lookups
52
+ - **Purpose**: "Find THE object by unique field value"
53
+ - **Storage**: HashKey for O(1) field-to-object mapping
54
+ - **Use for**: Email lookups, username searches, unique IDs
55
+ - **Example**: Find customer by email, find employee by badge number
56
+ - **Atomicity**: Transactions for updates (remove old + add new)
57
+
58
+ ```ruby
59
+ unique_index :email, :email_index, within: Organization
60
+ # Creates: org.find_by_email(), org.find_all_by_email()
61
+ ```
62
+
63
+ ### multi_index - Fast Multi-Value Lookups
64
+ - **Purpose**: "Find ALL objects by shared field value"
65
+ - **Storage**: UnsortedSet for O(1) field-to-objects mapping
66
+ - **Use for**: Grouping by department, status, category, tags
67
+ - **Example**: All employees in a department, all tasks with status
68
+ - **Atomicity**: Transactions for updates (remove from old set + add to new set)
69
+
70
+ ```ruby
71
+ multi_index :department, :dept_index, within: Organization
72
+ # Creates: org.sample_from_department(dept, count), org.find_all_by_department(dept)
73
+ ```
74
+
75
+ </details>
76
+
77
+ > [!NOTE]
78
+ > **Scoping Patterns**: `unique_index` and `multi_index` use the `within:` parameter for instance-scoping, while participation uses distinct method names (`participates_in` vs `class_participates_in`) to reflect fundamentally different semantics (instance collections vs auto-tracking all instances).
79
+
80
+ > [!TIP]
81
+ > **Quick Decision Guide**
82
+ > - Need to store a collection of objects? → `participates_in`
83
+ > - Need to find ONE object by unique field? → `unique_index`
84
+ > - Need to find MANY objects by shared field? → `multi_index`
85
+ > - Combination? → Use all three together (very common)
86
+
87
+ ```ruby
88
+ class Customer < Familia::Horreum
89
+ feature :relationships
90
+
91
+ participates_in Organization, :members # Customer belongs to org
92
+ unique_index :email, :email_index, within: Organization # Find by unique email
93
+ end
94
+ ```
95
+
96
+ > [!NOTE]
97
+ > **Key**: `participates_in` = collections, `unique_index` = unique lookups, `multi_index` = group lookups.
@@ -0,0 +1,104 @@
1
+ # lib/familia/features/relationships/collection_operations.rb
2
+
3
+ module Familia
4
+ module Features
5
+ module Relationships
6
+ # Shared collection operations for Participation module
7
+ # Provides common methods for working with Horreum-managed DataType collections
8
+ # Used by both ParticipantMethods and TargetMethods to reduce duplication
9
+ module CollectionOperations
10
+ using Familia::Refinements::StylizeWords
11
+
12
+ # Ensure a target class has the specified DataType field defined
13
+ # @param target_class [Class] The class that should have the collection
14
+ # @param collection_name [Symbol] Name of the collection field
15
+ # @param type [Symbol] Collection type (:sorted_set, :set, :list)
16
+ def ensure_collection_field(target_class, collection_name, type)
17
+ return if target_class.method_defined?(collection_name)
18
+
19
+ target_class.send(type, collection_name)
20
+ end
21
+
22
+ # Add an item to a collection, handling type-specific operations
23
+ # @param collection [Familia::DataType] The collection to add to
24
+ # @param item [Object] The item to add (must respond to identifier)
25
+ # @param score [Float, nil] Score for sorted sets
26
+ # @param type [Symbol] Collection type
27
+ def add_to_collection(collection, item, type:, score: nil, target_class: nil, collection_name: nil)
28
+ case type
29
+ when :sorted_set
30
+ # Ensure score is never nil for sorted sets
31
+ score ||= calculate_item_score(item, target_class, collection_name)
32
+ collection.add(item.identifier, score)
33
+ when :list
34
+ # Lists use push/unshift operations
35
+ collection.add(item.identifier)
36
+ when :set
37
+ # Sets use simple add
38
+ collection.add(item.identifier)
39
+ else
40
+ raise ArgumentError, "Unknown collection type: #{type}"
41
+ end
42
+ end
43
+
44
+ # Remove an item from a collection
45
+ # @param collection [Familia::DataType] The collection to remove from
46
+ # @param item [Object] The item to remove (must respond to identifier)
47
+ # @param type [Symbol] Collection type
48
+ def remove_from_collection(collection, item, type: nil)
49
+ # All collection types support remove/delete
50
+ collection.remove(item.identifier)
51
+ end
52
+
53
+ # Check if an item is a member of a collection
54
+ # @param collection [Familia::DataType] The collection to check
55
+ # @param item [Object] The item to check (must respond to identifier)
56
+ # @return [Boolean] True if item is in collection
57
+ def member_of_collection?(collection, item)
58
+ collection.member?(item.identifier)
59
+ end
60
+
61
+ # Bulk add items to a collection using DataType methods
62
+ # @param collection [Familia::DataType] The collection to add to
63
+ # @param items [Array] Array of items to add
64
+ # @param type [Symbol] Collection type
65
+ def bulk_add_to_collection(collection, items, type:, target_class: nil, collection_name: nil)
66
+ return if items.empty?
67
+
68
+ case type
69
+ when :sorted_set
70
+ # Add items one by one for sorted sets to ensure proper scoring
71
+ items.each do |item|
72
+ score = calculate_item_score(item, target_class, collection_name)
73
+ collection.add(item.identifier, score)
74
+ end
75
+ when :set, :list
76
+ # For sets and lists, add items one by one using DataType methods
77
+ items.each do |item|
78
+ collection.add(item.identifier)
79
+ end
80
+ else
81
+ raise ArgumentError, "Unknown collection type: #{type}"
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # Calculate score for an item
88
+ # @param item [Object] The item to score
89
+ # @param target_class [Class, nil] The target class for participation scoring
90
+ # @param collection_name [Symbol, nil] The collection name for participation scoring
91
+ # @return [Float] The calculated score
92
+ def calculate_item_score(item, target_class = nil, collection_name = nil)
93
+ if item.respond_to?(:calculate_participation_score) && target_class && collection_name
94
+ item.calculate_participation_score(target_class, collection_name)
95
+ elsif item.respond_to?(:current_score)
96
+ item.current_score
97
+ else
98
+ Familia.now.to_f
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end