familia 2.0.0.pre5 → 2.0.0.pre7
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.
- checksums.yaml +4 -4
- data/.github/workflows/claude-code-review.yml +57 -0
- data/.github/workflows/claude.yml +71 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +3 -0
- data/CLAUDE.md +32 -10
- data/Gemfile +2 -2
- data/Gemfile.lock +4 -3
- data/docs/wiki/API-Reference.md +95 -18
- data/docs/wiki/Connection-Pooling-Guide.md +437 -0
- data/docs/wiki/Encrypted-Fields-Overview.md +40 -3
- data/docs/wiki/Expiration-Feature-Guide.md +596 -0
- data/docs/wiki/Feature-System-Guide.md +631 -0
- data/docs/wiki/Features-System-Developer-Guide.md +892 -0
- data/docs/wiki/Field-System-Guide.md +784 -0
- data/docs/wiki/Home.md +82 -15
- data/docs/wiki/Implementation-Guide.md +126 -33
- data/docs/wiki/Quantization-Feature-Guide.md +721 -0
- data/docs/wiki/Relationships-Guide.md +684 -0
- data/docs/wiki/Security-Model.md +65 -25
- data/docs/wiki/Transient-Fields-Guide.md +280 -0
- data/examples/bit_encoding_integration.rb +237 -0
- data/examples/redis_command_validation_example.rb +231 -0
- data/examples/relationships_basic.rb +273 -0
- data/lib/familia/base.rb +1 -1
- data/lib/familia/connection.rb +3 -3
- data/lib/familia/data_type/types/counter.rb +38 -0
- data/lib/familia/data_type/types/hashkey.rb +18 -0
- data/lib/familia/data_type/types/lock.rb +43 -0
- data/lib/familia/data_type/types/string.rb +9 -2
- data/lib/familia/data_type.rb +9 -6
- data/lib/familia/encryption/encrypted_data.rb +137 -0
- data/lib/familia/encryption/manager.rb +21 -4
- data/lib/familia/encryption/providers/aes_gcm_provider.rb +20 -0
- data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +20 -0
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +17 -3
- data/lib/familia/features/encrypted_fields/concealed_string.rb +293 -0
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +94 -26
- data/lib/familia/features/encrypted_fields.rb +413 -4
- data/lib/familia/features/expiration.rb +319 -33
- data/lib/familia/features/quantization.rb +385 -44
- data/lib/familia/features/relationships/cascading.rb +438 -0
- data/lib/familia/features/relationships/indexing.rb +370 -0
- data/lib/familia/features/relationships/membership.rb +503 -0
- data/lib/familia/features/relationships/permission_management.rb +264 -0
- data/lib/familia/features/relationships/querying.rb +620 -0
- data/lib/familia/features/relationships/redis_operations.rb +274 -0
- data/lib/familia/features/relationships/score_encoding.rb +442 -0
- data/lib/familia/features/relationships/tracking.rb +379 -0
- data/lib/familia/features/relationships.rb +466 -0
- data/lib/familia/features/safe_dump.rb +1 -1
- data/lib/familia/features/transient_fields/redacted_string.rb +1 -1
- data/lib/familia/features/transient_fields.rb +192 -10
- data/lib/familia/features.rb +2 -1
- data/lib/familia/field_type.rb +5 -2
- data/lib/familia/horreum/{connection.rb → core/connection.rb} +2 -8
- data/lib/familia/horreum/{database_commands.rb → core/database_commands.rb} +14 -3
- data/lib/familia/horreum/core/serialization.rb +535 -0
- data/lib/familia/horreum/{utils.rb → core/utils.rb} +0 -2
- data/lib/familia/horreum/core.rb +21 -0
- data/lib/familia/horreum/{settings.rb → shared/settings.rb} +0 -2
- data/lib/familia/horreum/{definition_methods.rb → subclass/definition.rb} +45 -29
- data/lib/familia/horreum/{management_methods.rb → subclass/management.rb} +9 -8
- data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
- data/lib/familia/horreum.rb +17 -17
- data/lib/familia/validation/command_recorder.rb +336 -0
- data/lib/familia/validation/expectations.rb +519 -0
- data/lib/familia/validation/test_helpers.rb +443 -0
- data/lib/familia/validation/validator.rb +412 -0
- data/lib/familia/validation.rb +140 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -1
- data/try/core/create_method_try.rb +240 -0
- data/try/core/database_consistency_try.rb +299 -0
- data/try/core/errors_try.rb +25 -4
- data/try/core/familia_try.rb +1 -1
- data/try/core/persistence_operations_try.rb +297 -0
- data/try/data_types/counter_try.rb +93 -0
- data/try/data_types/lock_try.rb +133 -0
- data/try/debugging/debug_aad_process.rb +82 -0
- data/try/debugging/debug_concealed_internal.rb +59 -0
- data/try/debugging/debug_concealed_reveal.rb +61 -0
- data/try/debugging/debug_context_aad.rb +68 -0
- data/try/debugging/debug_context_simple.rb +80 -0
- data/try/debugging/debug_cross_context.rb +62 -0
- data/try/debugging/debug_database_load.rb +64 -0
- data/try/debugging/debug_encrypted_json_check.rb +53 -0
- data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
- data/try/debugging/debug_exists_lifecycle.rb +54 -0
- data/try/debugging/debug_field_decrypt.rb +74 -0
- data/try/debugging/debug_fresh_cross_context.rb +73 -0
- data/try/debugging/debug_load_path.rb +66 -0
- data/try/debugging/debug_method_definition.rb +46 -0
- data/try/debugging/debug_method_resolution.rb +41 -0
- data/try/debugging/debug_minimal.rb +24 -0
- data/try/debugging/debug_provider.rb +68 -0
- data/try/debugging/debug_secure_behavior.rb +73 -0
- data/try/debugging/debug_string_class.rb +46 -0
- data/try/debugging/debug_test.rb +46 -0
- data/try/debugging/debug_test_design.rb +80 -0
- data/try/edge_cases/hash_symbolization_try.rb +1 -0
- data/try/edge_cases/reserved_keywords_try.rb +1 -0
- data/try/edge_cases/string_coercion_try.rb +2 -0
- data/try/encryption/encryption_core_try.rb +6 -4
- data/try/features/categorical_permissions_try.rb +515 -0
- data/try/features/encrypted_fields_core_try.rb +19 -11
- data/try/features/encrypted_fields_integration_try.rb +66 -70
- data/try/features/encrypted_fields_no_cache_security_try.rb +22 -8
- data/try/features/encrypted_fields_security_try.rb +151 -144
- data/try/features/encryption_fields/aad_protection_try.rb +108 -23
- data/try/features/encryption_fields/concealed_string_core_try.rb +253 -0
- data/try/features/encryption_fields/context_isolation_try.rb +30 -8
- data/try/features/encryption_fields/error_conditions_try.rb +6 -6
- data/try/features/encryption_fields/fresh_key_derivation_try.rb +20 -14
- data/try/features/encryption_fields/fresh_key_try.rb +27 -22
- data/try/features/encryption_fields/key_rotation_try.rb +16 -10
- data/try/features/encryption_fields/nonce_uniqueness_try.rb +15 -13
- data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
- data/try/features/encryption_fields/thread_safety_try.rb +6 -6
- data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
- data/try/features/feature_dependencies_try.rb +3 -3
- data/try/features/relationships_edge_cases_try.rb +145 -0
- data/try/features/relationships_performance_minimal_try.rb +132 -0
- data/try/features/relationships_performance_simple_try.rb +155 -0
- data/try/features/relationships_performance_try.rb +420 -0
- data/try/features/relationships_performance_working_try.rb +144 -0
- data/try/features/relationships_try.rb +237 -0
- data/try/features/safe_dump_try.rb +3 -0
- data/try/features/transient_fields/redacted_string_try.rb +2 -0
- data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
- data/try/features/transient_fields_core_try.rb +1 -1
- data/try/features/transient_fields_integration_try.rb +1 -1
- data/try/helpers/test_helpers.rb +26 -1
- data/try/horreum/base_try.rb +14 -8
- data/try/horreum/enhanced_conflict_handling_try.rb +3 -1
- data/try/horreum/initialization_try.rb +1 -1
- data/try/horreum/relations_try.rb +2 -2
- data/try/horreum/serialization_persistent_fields_try.rb +8 -8
- data/try/horreum/serialization_try.rb +39 -4
- data/try/models/customer_safe_dump_try.rb +1 -1
- data/try/models/customer_try.rb +1 -1
- data/try/validation/atomic_operations_try.rb.disabled +320 -0
- data/try/validation/command_validation_try.rb.disabled +207 -0
- data/try/validation/performance_validation_try.rb.disabled +324 -0
- data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
- metadata +81 -12
- data/TEST_COVERAGE.md +0 -40
- data/lib/familia/features/relatable_objects.rb +0 -125
- data/lib/familia/horreum/serialization.rb +0 -473
- data/try/features/relatable_objects_try.rb +0 -220
@@ -0,0 +1,274 @@
|
|
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
|