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
@@ -1,274 +0,0 @@
1
- # lib/familia/features/relationships/redis_operations.rb
2
-
3
- module Familia
4
- module Features
5
- module Relationships
6
- # Redis operations module providing atomic multi-collection operations
7
- # and native Redis set operations for relationships
8
- module RedisOperations
9
- # Execute multiple Redis operations atomically using MULTI/EXEC
10
- #
11
- # @param redis [Redis] Redis connection to use
12
- # @yield [Redis] Yields Redis connection in transaction context
13
- # @return [Array] Results from Redis transaction
14
- #
15
- # @example Atomic multi-collection update
16
- # atomic_operation(redis) do |tx|
17
- # tx.zadd("customer:123:domains", score, domain_id)
18
- # tx.zadd("team:456:domains", score, domain_id)
19
- # tx.hset("domain_index", domain_name, domain_id)
20
- # end
21
- def atomic_operation(redis = nil)
22
- redis ||= redis_connection
23
-
24
- redis.multi do |tx|
25
- yield tx if block_given?
26
- end
27
- end
28
-
29
- # Update object presence in multiple collections atomically
30
- #
31
- # @param collections [Array<Hash>] Array of collection configurations
32
- # @param action [Symbol] Action to perform (:add, :remove)
33
- # @param identifier [String] Object identifier
34
- # @param default_score [Float] Default score if not specified per collection
35
- #
36
- # @example Update presence in multiple collections
37
- # update_multiple_presence([
38
- # { key: "customer:123:domains", score: current_score },
39
- # { key: "team:456:domains", score: permission_encode(Time.now, :read) },
40
- # { key: "org:789:all_domains", score: current_score }
41
- # ], :add, domain.identifier)
42
- def update_multiple_presence(collections, action, identifier, default_score = nil)
43
- return unless collections&.any?
44
-
45
- redis = self.class.dbclient
46
-
47
- atomic_operation(redis) do |tx|
48
- collections.each do |collection_config|
49
- redis_key = collection_config[:key]
50
- score = collection_config[:score] || default_score || current_score
51
-
52
- case action
53
- when :add
54
- tx.zadd(redis_key, score, identifier)
55
- when :remove
56
- tx.zrem(redis_key, identifier)
57
- when :update
58
- # Use ZADD with XX flag to only update existing members
59
- tx.zadd(redis_key, score, identifier, xx: true)
60
- end
61
- end
62
- end
63
- end
64
-
65
- # Perform Redis set operations (union, intersection, difference) on sorted sets
66
- #
67
- # @param operation [Symbol] Operation type (:union, :intersection, :difference)
68
- # @param destination [String] Redis key for result storage
69
- # @param source_keys [Array<String>] Source Redis keys to operate on
70
- # @param weights [Array<Float>] Optional weights for union operations
71
- # @param aggregate [Symbol] Aggregation method (:sum, :min, :max)
72
- # @param ttl [Integer] TTL for destination key in seconds
73
- # @return [Integer] Number of elements in resulting set
74
- #
75
- # @example Union of accessible domains
76
- # set_operation(:union, "temp:accessible_domains:#{user_id}",
77
- # ["customer:domains", "team:domains", "org:domains"],
78
- # ttl: 300)
79
- def set_operation(operation, destination, source_keys, weights: nil, aggregate: :sum, ttl: nil)
80
- return 0 if source_keys.empty?
81
-
82
- redis = redis_connection
83
-
84
- atomic_operation(redis) do |tx|
85
- case operation
86
- when :union
87
- if weights
88
- tx.zunionstore(destination, source_keys.zip(weights).to_h, aggregate: aggregate)
89
- else
90
- tx.zunionstore(destination, source_keys, aggregate: aggregate)
91
- end
92
- when :intersection
93
- if weights
94
- tx.zinterstore(destination, source_keys.zip(weights).to_h, aggregate: aggregate)
95
- else
96
- tx.zinterstore(destination, source_keys, aggregate: aggregate)
97
- end
98
- when :difference
99
- first_key = source_keys.first
100
- other_keys = source_keys[1..] || []
101
-
102
- tx.zunionstore(destination, [first_key])
103
- other_keys.each do |key|
104
- members = redis.zrange(key, 0, -1)
105
- tx.zrem(destination, members) if members.any?
106
- end
107
- end
108
-
109
- tx.expire(destination, ttl) if ttl
110
- end
111
-
112
- redis.zcard(destination)
113
- end
114
-
115
- # Create temporary Redis key with automatic cleanup
116
- #
117
- # @param base_name [String] Base name for the temporary key
118
- # @param ttl [Integer] TTL in seconds (default: 300)
119
- # @return [String] Generated temporary key name
120
- #
121
- # @example
122
- # temp_key = create_temp_key("user_accessible_domains", 600)
123
- # #=> "temp:user_accessible_domains:1704067200:abc123"
124
- def create_temp_key(base_name, ttl = 300)
125
- timestamp = Time.now.to_i
126
- random_suffix = SecureRandom.hex(3)
127
- temp_key = "temp:#{base_name}:#{timestamp}:#{random_suffix}"
128
-
129
- # Set immediate expiry to ensure cleanup even if operation fails
130
- redis_connection.expire(temp_key, ttl)
131
-
132
- temp_key
133
- end
134
-
135
- # Batch add multiple items to a sorted set
136
- #
137
- # @param redis_key [String] Redis sorted set key
138
- # @param items [Array<Hash>] Array of {member: String, score: Float} hashes
139
- # @param mode [Symbol] Add mode (:normal, :nx, :xx, :lt, :gt)
140
- #
141
- # @example Batch add domains with scores
142
- # batch_zadd("customer:domains", [
143
- # { member: "domain1", score: encode_score(Time.now, permission: :read) },
144
- # { member: "domain2", score: encode_score(Time.now, permission: :write) }
145
- # ])
146
- def batch_zadd(redis_key, items, mode: :normal)
147
- return 0 if items.empty?
148
-
149
- redis = redis_connection
150
- zadd_args = items.flat_map { |item| [item[:score], item[:member]] }
151
-
152
- case mode
153
- when :nx
154
- redis.zadd(redis_key, zadd_args, nx: true)
155
- when :xx
156
- redis.zadd(redis_key, zadd_args, xx: true)
157
- when :lt
158
- redis.zadd(redis_key, zadd_args, lt: true)
159
- when :gt
160
- redis.zadd(redis_key, zadd_args, gt: true)
161
- else
162
- redis.zadd(redis_key, zadd_args)
163
- end
164
- end
165
-
166
- # Query sorted set with score filtering and permission checking
167
- #
168
- # @param redis_key [String] Redis sorted set key
169
- # @param start_score [Float] Minimum score (inclusive)
170
- # @param end_score [Float] Maximum score (inclusive)
171
- # @param offset [Integer] Offset for pagination
172
- # @param count [Integer] Maximum number of results
173
- # @param with_scores [Boolean] Include scores in results
174
- # @param min_permission [Symbol] Minimum permission level required
175
- # @return [Array] Query results
176
- #
177
- # @example Query domains with read permission or higher
178
- # query_by_score("customer:domains",
179
- # encode_score(1.hour.ago, 0),
180
- # encode_score(Time.now, MAX_METADATA),
181
- # min_permission: :read)
182
- def query_by_score(redis_key, start_score = '-inf', end_score = '+inf',
183
- offset: 0, count: -1, with_scores: false, min_permission: nil)
184
- self.class.dbclient
185
-
186
- # Adjust score range for permission filtering
187
- if min_permission
188
- permission_value = ScoreEncoding.permission_level_value(min_permission)
189
- # Ensure minimum score includes required permission level
190
- if start_score.is_a?(Numeric)
191
- decoded = decode_score(start_score)
192
- if decoded[:permissions] < permission_value
193
- start_score = encode_score(decoded[:timestamp],
194
- permission_value)
195
- end
196
- else
197
- start_score = encode_score(0, permission_value)
198
- end
199
- end
200
-
201
- options = {
202
- limit: (count.positive? ? [offset, count] : nil),
203
- with_scores: with_scores
204
- }.compact
205
-
206
- results = dbclient.zrangebyscore(redis_key, start_score, end_score, **options)
207
-
208
- # Filter results by permission if needed using correct bitwise operations
209
- if min_permission && with_scores
210
- permission_mask = ScoreEncoding.permission_level_value(min_permission)
211
- results = results.select do |_member, score|
212
- decoded = decode_score(score)
213
- # Use bitwise AND to check if permission mask is satisfied
214
- decoded[:permissions].allbits?(permission_mask)
215
- end
216
- end
217
-
218
- results
219
- end
220
-
221
- # Clean up expired temporary keys
222
- #
223
- # @param pattern [String] Pattern to match temporary keys
224
- # @param batch_size [Integer] Number of keys to process at once
225
- #
226
- # @example Clean up old temporary keys
227
- # cleanup_temp_keys("temp:user_*", 100)
228
- def cleanup_temp_keys(pattern = 'temp:*', batch_size = 100)
229
- self.class.dbclient
230
- cursor = 0
231
-
232
- loop do
233
- cursor, keys = dbclient.scan(cursor, match: pattern, count: batch_size)
234
-
235
- if keys.any?
236
- # Check TTL and remove keys that should have expired
237
- keys.each_slice(batch_size) do |key_batch|
238
- dbclient.pipelined do |pipeline|
239
- key_batch.each do |key|
240
- ttl = dbclient.ttl(key)
241
- pipeline.del(key) if ttl == -1 # Key exists but has no TTL
242
- end
243
- end
244
- end
245
- end
246
-
247
- break if cursor.zero?
248
- end
249
- end
250
-
251
- # Get Redis connection for the current class or instance
252
- def redis_connection
253
- if self.class.respond_to?(:dbclient)
254
- self.class.dbclient
255
- elsif respond_to?(:dbclient)
256
- dbclient
257
- else
258
- Familia.dbclient
259
- end
260
- end
261
-
262
- private
263
-
264
- # Validate Redis key format
265
- def validate_redis_key(key)
266
- raise ArgumentError, 'Redis key cannot be nil or empty' if key.nil? || key.empty?
267
- raise ArgumentError, 'Redis key must be a string' unless key.is_a?(String)
268
-
269
- key
270
- end
271
- end
272
- end
273
- end
274
- end
@@ -1,418 +0,0 @@
1
- # lib/familia/features/relationships/tracking.rb
2
-
3
- module Familia
4
- module Features
5
- module Relationships
6
- # Tracking module for tracked_in relationships using Redis sorted sets
7
- # Provides multi-presence support where objects can exist in multiple collections
8
- module Tracking
9
- # Class-level tracking configurations
10
- def self.included(base)
11
- base.extend ClassMethods
12
- base.include InstanceMethods
13
- super
14
- end
15
-
16
- module ClassMethods
17
- # Simple singularize method (basic implementation)
18
- def singularize_word(word)
19
- word = word.to_s
20
- # Basic English pluralization rules (simplified)
21
- if word.end_with?('ies')
22
- "#{word[0..-4]}y"
23
- elsif word.end_with?('es') && word.length > 3
24
- word[0..-3]
25
- elsif word.end_with?('s') && word.length > 1
26
- word[0..-2]
27
- else
28
- word
29
- end
30
- end
31
-
32
- # Simple camelize method (basic implementation)
33
- def camelize_word(word)
34
- word.to_s.split('_').map(&:capitalize).join
35
- end
36
-
37
- # Define a class-level tracked collection
38
- #
39
- # @param collection_name [Symbol] Name of the class-level collection
40
- # @param score [Symbol, Proc, nil] How to calculate the score
41
- # @param on_destroy [Symbol] What to do when object is destroyed (:remove, :ignore)
42
- #
43
- # @example Class-level tracking (using class_ prefix convention)
44
- # class_tracked_in :all_customers, score: :created_at
45
- # class_tracked_in :active_users, score: -> { status == 'active' ? Time.now.to_i : 0 }
46
- def class_tracked_in(collection_name, score: nil, on_destroy: :remove)
47
-
48
- klass_name = (name || self.to_s).downcase
49
-
50
- # Store metadata for this tracking relationship
51
- tracking_relationships << {
52
- context_class: klass_name,
53
- context_class_name: name || self.to_s,
54
- collection_name: collection_name,
55
- score: score,
56
- on_destroy: on_destroy
57
- }
58
-
59
- # Generate class-level collection methods
60
- generate_tracking_class_methods(self, collection_name)
61
-
62
- # Generate instance methods for class-level tracking
63
- generate_tracking_instance_methods('class', collection_name, score)
64
- end
65
-
66
- # Define a tracked_in relationship
67
- #
68
- # @param context_class [Class, Symbol] The class that owns the collection
69
- # @param collection_name [Symbol] Name of the collection
70
- # @param score [Symbol, Proc, nil] How to calculate the score
71
- # @param on_destroy [Symbol] What to do when object is destroyed (:remove, :ignore)
72
- #
73
- # @example Basic tracking
74
- # tracked_in Customer, :domains, score: :created_at
75
- #
76
- # @example Multi-presence tracking
77
- # tracked_in Customer, :domains, score: -> { permission_encode(created_at, permission_level) }
78
- # tracked_in Team, :domains, score: :added_at
79
- # tracked_in Organization, :all_domains, score: :created_at
80
- def tracked_in(context_class, collection_name, score: nil, on_destroy: :remove)
81
-
82
- # Handle class context
83
- if context_class.is_a?(Class)
84
- class_name = context_class.name
85
- context_class_name = if class_name.include?('::')
86
- # Extract the last part after the last ::
87
- class_name.split('::').last
88
- else
89
- class_name
90
- end
91
- else
92
- context_class_name = camelize_word(context_class)
93
- end
94
-
95
- # Store metadata for this tracking relationship
96
- tracking_relationships << {
97
- context_class: context_class,
98
- context_class_name: context_class_name,
99
- collection_name: collection_name,
100
- score: score,
101
- on_destroy: on_destroy
102
- }
103
-
104
- # Generate context class methods
105
- generate_context_class_methods(context_class, collection_name)
106
-
107
- # Generate instance methods on this class
108
- generate_tracking_instance_methods(context_class_name, collection_name, score)
109
- end
110
-
111
- # Get all tracking relationships for this class
112
- def tracking_relationships
113
- @tracking_relationships ||= []
114
- end
115
-
116
- private
117
-
118
- # Generate class-level collection methods (e.g., User.all_users)
119
- def generate_tracking_class_methods(target_class, collection_name)
120
- # Generate class-level collection getter method
121
- target_class.define_singleton_method("#{collection_name}") do
122
- collection_key = "#{self.name.downcase}:#{collection_name}"
123
- Familia::SortedSet.new(nil, dbkey: collection_key, logical_database: logical_database)
124
- end
125
-
126
- # Generate class-level add method (e.g., User.add_to_all_users)
127
- target_class.define_singleton_method("add_to_#{collection_name}") do |item, score = nil|
128
- collection = send("#{collection_name}")
129
-
130
- # Calculate score if not provided
131
- score ||= if item.respond_to?(:calculate_tracking_score)
132
- item.calculate_tracking_score('class', collection_name)
133
- else
134
- item.current_score
135
- end
136
-
137
- # Ensure score is never nil
138
- score = item.current_score if score.nil?
139
-
140
- collection.add(score, item.identifier)
141
- end
142
-
143
- # Generate class-level remove method
144
- target_class.define_singleton_method("remove_from_#{collection_name}") do |item|
145
- collection = send("#{collection_name}")
146
- collection.delete(item.identifier)
147
- end
148
- end
149
-
150
- # Generate methods on the context class (e.g., Customer.domains)
151
- def generate_context_class_methods(context_class, collection_name)
152
- # Resolve context class if it's a symbol/string
153
- actual_context_class = context_class.is_a?(Class) ? context_class : Object.const_get(camelize_word(context_class))
154
-
155
- # Generate collection getter method
156
- actual_context_class.define_method(collection_name) do
157
- collection_key = "#{self.class.name.downcase}:#{identifier}:#{collection_name}"
158
- Familia::SortedSet.new(nil, dbkey: collection_key, logical_database: self.class.logical_database)
159
- end
160
-
161
- # Generate add method (e.g., Customer#add_domain)
162
- actual_context_class.define_method("add_#{singularize_word(collection_name)}") do |item, score = nil|
163
- collection = send(collection_name)
164
-
165
- # Calculate score if not provided
166
- score ||= if item.respond_to?(:calculate_tracking_score)
167
- item.calculate_tracking_score(self.class, collection_name)
168
- else
169
- item.current_score
170
- end
171
-
172
- # Ensure score is never nil
173
- score = item.current_score if score.nil?
174
-
175
- collection.add(score, item.identifier)
176
- end
177
-
178
- # Generate remove method (e.g., Customer#remove_domain)
179
- actual_context_class.define_method("remove_#{singularize_word(collection_name)}") do |item|
180
- collection = send(collection_name)
181
- collection.delete(item.identifier)
182
- end
183
-
184
- # Generate bulk add method (e.g., Customer#add_domains)
185
- actual_context_class.define_method("add_#{collection_name}") do |items|
186
- return if items.empty?
187
-
188
- collection = send(collection_name)
189
-
190
- # Prepare batch data
191
- batch_data = items.map do |item|
192
- score = if item.respond_to?(:calculate_tracking_score)
193
- item.calculate_tracking_score(self.class, collection_name)
194
- else
195
- item.current_score
196
- end
197
- # Ensure score is never nil
198
- score = item.current_score if score.nil?
199
- { member: item.identifier, score: score }
200
- end
201
-
202
- # Use batch operation from RedisOperations
203
- collection.dbclient.pipelined do |pipeline|
204
- batch_data.each do |data|
205
- pipeline.zadd(collection.rediskey, data[:score], data[:member])
206
- end
207
- end
208
- end
209
-
210
- # Generate query methods with score filtering
211
- actual_context_class.define_method("#{collection_name}_with_permission") do |min_permission = :read|
212
- collection = send(collection_name)
213
- permission_score = ScoreEncoding.permission_encode(0, min_permission)
214
-
215
- collection.zrangebyscore(permission_score, '+inf', with_scores: true)
216
- end
217
- end
218
-
219
- # Generate instance methods on the tracked class
220
- def generate_tracking_instance_methods(context_class_name, collection_name, _score_calculator)
221
- # Method to check if this object is in a specific collection
222
- # e.g., domain.in_customer_domains?(customer)
223
- define_method("in_#{context_class_name.downcase}_#{collection_name}?") do |context_instance|
224
- collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
225
- dbclient.zscore(collection_key, identifier) != nil
226
- end
227
-
228
- # Method to add this object to a specific collection
229
- # e.g., domain.add_to_customer_domains(customer, score)
230
- define_method("add_to_#{context_class_name.downcase}_#{collection_name}") do |context_instance, score = nil|
231
- collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
232
-
233
- score ||= calculate_tracking_score(context_class_name, collection_name)
234
-
235
- # Ensure score is never nil
236
- score = current_score if score.nil?
237
-
238
- dbclient.zadd(collection_key, score, identifier)
239
- end
240
-
241
- # Method to remove this object from a specific collection
242
- # e.g., domain.remove_from_customer_domains(customer)
243
- define_method("remove_from_#{context_class_name.downcase}_#{collection_name}") do |context_instance|
244
- collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
245
- dbclient.zrem(collection_key, identifier)
246
- end
247
-
248
- # Method to get score in a specific collection
249
- # e.g., domain.score_in_customer_domains(customer)
250
- define_method("score_in_#{context_class_name.downcase}_#{collection_name}") do |context_instance|
251
- collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
252
- dbclient.zscore(collection_key, identifier)
253
- end
254
-
255
- # Method to update score in a specific collection
256
- # e.g., domain.update_score_in_customer_domains(customer, new_score)
257
- define_method("update_score_in_#{context_class_name.downcase}_#{collection_name}") do |context_instance, new_score|
258
- collection_key = "#{context_class_name.downcase}:#{context_instance.identifier}:#{collection_name}"
259
- dbclient.zadd(collection_key, new_score, identifier, xx: true) # Only update existing
260
- end
261
- end
262
- end
263
-
264
- # Instance methods for tracked objects
265
- module InstanceMethods
266
- # Calculate the appropriate score for a tracking relationship
267
- #
268
- # @param context_class [Class] The context class (e.g., Customer)
269
- # @param collection_name [Symbol] The collection name (e.g., :domains)
270
- # @return [Float] Calculated score
271
- def calculate_tracking_score(context_class, collection_name)
272
- # Find the tracking configuration
273
- tracking_config = self.class.tracking_relationships.find do |config|
274
- config[:context_class] == context_class && config[:collection_name] == collection_name
275
- end
276
-
277
- return current_score unless tracking_config
278
-
279
- score_calculator = tracking_config[:score]
280
-
281
- case score_calculator
282
- when Symbol
283
- # Field name or method name
284
- if respond_to?(score_calculator)
285
- value = send(score_calculator)
286
- if value.respond_to?(:to_f)
287
- value.to_f
288
- elsif value.respond_to?(:to_i)
289
- encode_score(value, 0)
290
- else
291
- current_score
292
- end
293
- else
294
- current_score
295
- end
296
- when Proc
297
- # Execute proc in context of this instance
298
- result = instance_exec(&score_calculator)
299
- # Ensure we get a numeric result
300
- if result.nil?
301
- current_score
302
- elsif result.respond_to?(:to_f)
303
- result.to_f
304
- else
305
- current_score
306
- end
307
- when Numeric
308
- score_calculator.to_f
309
- else
310
- current_score
311
- end
312
- end
313
-
314
- # Update presence in all tracked collections atomically
315
- def update_all_tracking_collections
316
- return unless self.class.respond_to?(:tracking_relationships)
317
-
318
- []
319
-
320
- self.class.tracking_relationships.each do |config|
321
- config[:context_class_name]
322
- config[:collection_name]
323
-
324
- # This is a simplified version - in practice, you'd need to know
325
- # which specific instances this object should be tracked in
326
- # For now, we'll skip the automatic update and rely on explicit calls
327
- end
328
- end
329
-
330
- # Add to class-level tracking collections automatically
331
- def add_to_class_tracking_collections
332
- return unless self.class.respond_to?(:tracking_relationships)
333
-
334
- self.class.tracking_relationships.each do |config|
335
- context_class_name = config[:context_class_name]
336
- context_class = config[:context_class]
337
- collection_name = config[:collection_name]
338
-
339
- # Only auto-add to class-level collections (where context_class matches self.class)
340
- if context_class_name.downcase == self.class.name.downcase
341
- # Call the class method to add this object
342
- self.class.send("add_to_#{collection_name}", self)
343
- end
344
- end
345
- end
346
-
347
- # Remove from all tracking collections (used during destroy)
348
- def remove_from_all_tracking_collections
349
- return unless self.class.respond_to?(:tracking_relationships)
350
-
351
- # Get all possible collection keys this object might be in
352
- # This is expensive but necessary for cleanup
353
- redis_conn = redis
354
- pattern = '*:*:*' # This could be optimized with better key patterns
355
-
356
- cursor = 0
357
- matching_keys = []
358
-
359
- loop do
360
- cursor, keys = redis_conn.scan(cursor, match: pattern, count: 1000)
361
- matching_keys.concat(keys)
362
- break if cursor.zero?
363
- end
364
-
365
- # Filter keys that might contain this object and remove it
366
- redis_conn.pipelined do |pipeline|
367
- matching_keys.each do |key|
368
- # Check if this key matches any of our tracking relationships
369
- self.class.tracking_relationships.each do |config|
370
- context_class_name = config[:context_class_name].downcase
371
- collection_name = config[:collection_name]
372
-
373
- if key.include?(context_class_name) && key.include?(collection_name.to_s)
374
- pipeline.zrem(key, identifier)
375
- end
376
- end
377
- end
378
- end
379
- end
380
-
381
- # Get all collections this object appears in
382
- #
383
- # @return [Array<Hash>] Array of collection information
384
- def tracking_collections_membership
385
- return [] unless self.class.respond_to?(:tracking_relationships)
386
-
387
- memberships = []
388
-
389
- self.class.tracking_relationships.each do |config|
390
- context_class_name = config[:context_class_name]
391
- collection_name = config[:collection_name]
392
-
393
- # Find all instances of context_class where this object appears
394
- # This is simplified - in practice you'd need a more efficient approach
395
- pattern = "#{context_class_name.downcase}:*:#{collection_name}"
396
-
397
- dbclient.scan_each(match: pattern) do |key|
398
- score = dbclient.zscore(key, identifier)
399
- if score
400
- context_id = key.split(':')[1]
401
- memberships << {
402
- context_class: context_class_name,
403
- context_id: context_id,
404
- collection_name: collection_name,
405
- score: score,
406
- decoded_score: decode_score(score)
407
- }
408
- end
409
- end
410
- end
411
-
412
- memberships
413
- end
414
- end
415
- end
416
- end
417
- end
418
- end