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,466 @@
|
|
1
|
+
# lib/familia/features/relationships.rb
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require_relative 'relationships/score_encoding'
|
5
|
+
require_relative 'relationships/redis_operations'
|
6
|
+
require_relative 'relationships/tracking'
|
7
|
+
require_relative 'relationships/indexing'
|
8
|
+
require_relative 'relationships/membership'
|
9
|
+
require_relative 'relationships/cascading'
|
10
|
+
require_relative 'relationships/querying'
|
11
|
+
require_relative 'relationships/permission_management'
|
12
|
+
|
13
|
+
module Familia
|
14
|
+
module Features
|
15
|
+
# Unified Relationships feature for Familia v2
|
16
|
+
#
|
17
|
+
# This feature merges the functionality of relatable_objects and relationships
|
18
|
+
# into a single, Redis-native implementation that embraces the "where does this appear?"
|
19
|
+
# philosophy rather than "who owns this?".
|
20
|
+
#
|
21
|
+
# Key improvements in v2:
|
22
|
+
# - Multi-presence: Objects can exist in multiple collections simultaneously
|
23
|
+
# - Score encoding: Metadata embedded in Redis scores for efficiency
|
24
|
+
# - Collision-free: Method names include collection names to prevent conflicts
|
25
|
+
# - Redis-native: All operations use Redis commands, no Ruby iteration
|
26
|
+
# - Atomic operations: Multi-collection updates happen atomically
|
27
|
+
#
|
28
|
+
# Breaking changes from v1:
|
29
|
+
# - Single feature: Use `feature :relationships` instead of separate features
|
30
|
+
# - Simplified identifier: Use `identifier :field` instead of `identifier_field :field`
|
31
|
+
# - No ownership concept: Remove `owned_by`, use multi-presence instead
|
32
|
+
# - Method naming: Generated methods include collection names for uniqueness
|
33
|
+
# - Score encoding: Scores can carry metadata like permissions
|
34
|
+
#
|
35
|
+
# @example Basic usage
|
36
|
+
# class Domain < Familia::Horreum
|
37
|
+
# feature :relationships
|
38
|
+
#
|
39
|
+
# identifier :domain_id
|
40
|
+
# field :domain_id
|
41
|
+
# field :display_name
|
42
|
+
# field :created_at
|
43
|
+
# field :permission_bits
|
44
|
+
#
|
45
|
+
# # Multi-presence tracking with score encoding
|
46
|
+
# tracked_in Customer, :domains,
|
47
|
+
# score: -> { permission_encode(created_at, permission_bits) }
|
48
|
+
# tracked_in Team, :domains, score: :added_at
|
49
|
+
# tracked_in Organization, :all_domains, score: :created_at
|
50
|
+
#
|
51
|
+
# # O(1) lookups with Redis hashes
|
52
|
+
# indexed_by :display_name, in: Customer, index_name: :domain_index
|
53
|
+
# indexed_by :display_name, in: :global, index_name: :global_domain_index
|
54
|
+
#
|
55
|
+
# # Context-aware membership (no method collisions)
|
56
|
+
# member_of Customer, :domains
|
57
|
+
# member_of Team, :domains
|
58
|
+
# member_of Organization, :domains
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# @example Generated methods (collision-free)
|
62
|
+
# # Tracking methods
|
63
|
+
# Customer.domains # => Familia::SortedSet
|
64
|
+
# Customer.add_domain(domain, score) # Add to customer's domains
|
65
|
+
# domain.in_customer_domains?(customer) # Check membership
|
66
|
+
#
|
67
|
+
# # Indexing methods
|
68
|
+
# Customer.find_by_display_name(name) # O(1) lookup
|
69
|
+
# Domain.find_by_display_name_globally(name) # Global lookup
|
70
|
+
#
|
71
|
+
# # Membership methods (collision-free naming)
|
72
|
+
# domain.add_to_customer_domains(customer) # Specific collection
|
73
|
+
# domain.add_to_team_domains(team) # Different collection
|
74
|
+
# domain.in_customer_domains?(customer) # Check specific membership
|
75
|
+
#
|
76
|
+
# @example Score encoding for permissions
|
77
|
+
# # Encode permission in score
|
78
|
+
# score = domain.permission_encode(Time.now, :write)
|
79
|
+
# # => 1704067200.004 (timestamp + permission bits)
|
80
|
+
#
|
81
|
+
# # Decode permission from score
|
82
|
+
# decoded = domain.permission_decode(score)
|
83
|
+
# # => { timestamp: 1704067200, permissions: 4, permission_list: [:write] }
|
84
|
+
#
|
85
|
+
# # Query with permission filtering
|
86
|
+
# Customer.domains_with_permission(:read)
|
87
|
+
#
|
88
|
+
# @example Multi-collection operations
|
89
|
+
# # Atomic updates across multiple collections
|
90
|
+
# domain.update_multiple_presence([
|
91
|
+
# { key: "customer:123:domains", score: current_score },
|
92
|
+
# { key: "team:456:domains", score: permission_encode(Time.now, :read) }
|
93
|
+
# ], :add, domain.identifier)
|
94
|
+
#
|
95
|
+
# # Set operations on collections
|
96
|
+
# accessible = Domain.union_collections([
|
97
|
+
# { owner: customer, collection: :domains },
|
98
|
+
# { owner: team, collection: :domains }
|
99
|
+
# ], min_permission: :read)
|
100
|
+
module Relationships
|
101
|
+
# Feature initialization
|
102
|
+
def self.included(base)
|
103
|
+
puts "[DEBUG] Relationships included in #{base}"
|
104
|
+
base.extend ClassMethods
|
105
|
+
base.include InstanceMethods
|
106
|
+
|
107
|
+
# Include all relationship submodules and their class methods
|
108
|
+
base.include ScoreEncoding
|
109
|
+
base.include RedisOperations
|
110
|
+
|
111
|
+
puts '[DEBUG] Including Tracking module'
|
112
|
+
base.include Tracking
|
113
|
+
puts '[DEBUG] Extending with Tracking::ClassMethods'
|
114
|
+
base.extend Tracking::ClassMethods
|
115
|
+
puts "[DEBUG] Base now responds to tracked_in: #{base.respond_to?(:tracked_in)}"
|
116
|
+
|
117
|
+
base.include Indexing
|
118
|
+
base.extend Indexing::ClassMethods
|
119
|
+
|
120
|
+
base.include Membership
|
121
|
+
base.extend Membership::ClassMethods
|
122
|
+
|
123
|
+
base.include Cascading
|
124
|
+
base.extend Cascading::ClassMethods
|
125
|
+
|
126
|
+
base.include Querying
|
127
|
+
base.extend Querying::ClassMethods
|
128
|
+
end
|
129
|
+
|
130
|
+
# Error classes
|
131
|
+
class RelationshipError < StandardError; end
|
132
|
+
class InvalidIdentifierError < RelationshipError; end
|
133
|
+
class InvalidScoreError < RelationshipError; end
|
134
|
+
class CascadeError < RelationshipError; end
|
135
|
+
|
136
|
+
module ClassMethods
|
137
|
+
# Define the identifier for this class (replaces identifier_field)
|
138
|
+
# This is a compatibility wrapper around the existing identifier_field method
|
139
|
+
#
|
140
|
+
# @param field [Symbol] The field to use as identifier
|
141
|
+
# @return [Symbol] The identifier field
|
142
|
+
#
|
143
|
+
# @example
|
144
|
+
# identifier :domain_id
|
145
|
+
def identifier(field = nil)
|
146
|
+
return identifier_field(field) if field
|
147
|
+
|
148
|
+
identifier_field
|
149
|
+
end
|
150
|
+
|
151
|
+
# Generate a secure temporary identifier
|
152
|
+
def generate_identifier
|
153
|
+
SecureRandom.hex(8)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Get all relationship configurations for this class
|
157
|
+
def relationship_configs
|
158
|
+
configs = {}
|
159
|
+
|
160
|
+
configs[:tracking] = tracking_relationships if respond_to?(:tracking_relationships)
|
161
|
+
configs[:indexing] = indexing_relationships if respond_to?(:indexing_relationships)
|
162
|
+
configs[:membership] = membership_relationships if respond_to?(:membership_relationships)
|
163
|
+
|
164
|
+
configs
|
165
|
+
end
|
166
|
+
|
167
|
+
# Validate relationship configurations
|
168
|
+
def validate_relationships!
|
169
|
+
errors = []
|
170
|
+
|
171
|
+
# Check for method name collisions
|
172
|
+
method_names = []
|
173
|
+
|
174
|
+
if respond_to?(:tracking_relationships)
|
175
|
+
tracking_relationships.each do |config|
|
176
|
+
context_name = config[:context_class_name].downcase
|
177
|
+
collection_name = config[:collection_name]
|
178
|
+
|
179
|
+
method_names << "in_#{context_name}_#{collection_name}?"
|
180
|
+
method_names << "add_to_#{context_name}_#{collection_name}"
|
181
|
+
method_names << "remove_from_#{context_name}_#{collection_name}"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
if respond_to?(:membership_relationships)
|
186
|
+
membership_relationships.each do |config|
|
187
|
+
owner_name = config[:owner_class_name].downcase
|
188
|
+
collection_name = config[:collection_name]
|
189
|
+
|
190
|
+
method_names << "in_#{owner_name}_#{collection_name}?"
|
191
|
+
method_names << "add_to_#{owner_name}_#{collection_name}"
|
192
|
+
method_names << "remove_from_#{owner_name}_#{collection_name}"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
# Check for duplicates
|
197
|
+
duplicates = method_names.group_by(&:itself).select { |_, v| v.size > 1 }.keys
|
198
|
+
errors << "Method name collisions detected: #{duplicates.join(', ')}" if duplicates.any?
|
199
|
+
|
200
|
+
# Validate identifier field exists
|
201
|
+
id_field = identifier
|
202
|
+
unless instance_methods.include?(id_field) || method_defined?(id_field)
|
203
|
+
errors << "Identifier field '#{id_field}' is not defined"
|
204
|
+
end
|
205
|
+
|
206
|
+
raise RelationshipError, "Relationship validation failed: #{errors.join('; ')}" if errors.any?
|
207
|
+
|
208
|
+
true
|
209
|
+
end
|
210
|
+
|
211
|
+
# Create a new instance with relationships initialized
|
212
|
+
def create_with_relationships(attributes = {})
|
213
|
+
instance = new(attributes)
|
214
|
+
instance.initialize_relationships
|
215
|
+
instance
|
216
|
+
end
|
217
|
+
|
218
|
+
# Class method wrapper for create_temp_key
|
219
|
+
def create_temp_key(base_name, ttl = 300)
|
220
|
+
timestamp = Time.now.to_i
|
221
|
+
random_suffix = SecureRandom.hex(3)
|
222
|
+
temp_key = "temp:#{base_name}:#{timestamp}:#{random_suffix}"
|
223
|
+
|
224
|
+
# Set immediate expiry to ensure cleanup even if operation fails
|
225
|
+
if respond_to?(:dbclient)
|
226
|
+
dbclient.expire(temp_key, ttl)
|
227
|
+
else
|
228
|
+
Familia.dbclient.expire(temp_key, ttl)
|
229
|
+
end
|
230
|
+
|
231
|
+
temp_key
|
232
|
+
end
|
233
|
+
|
234
|
+
# Include core score encoding methods at class level
|
235
|
+
include ScoreEncoding
|
236
|
+
|
237
|
+
private
|
238
|
+
|
239
|
+
# Simple constantize method to convert string to constant
|
240
|
+
def constantize_class_name(class_name)
|
241
|
+
class_name.split('::').reduce(Object) { |mod, name| mod.const_get(name) }
|
242
|
+
rescue NameError
|
243
|
+
# If the class doesn't exist, return nil
|
244
|
+
nil
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
module InstanceMethods
|
249
|
+
# Get the identifier value for this instance
|
250
|
+
# Uses the existing Horreum identifier infrastructure
|
251
|
+
def identifier
|
252
|
+
id_field = self.class.identifier_field
|
253
|
+
send(id_field) if respond_to?(id_field)
|
254
|
+
end
|
255
|
+
|
256
|
+
# Set the identifier value for this instance
|
257
|
+
def identifier=(value)
|
258
|
+
id_field = self.class.identifier_field
|
259
|
+
send("#{id_field}=", value) if respond_to?("#{id_field}=")
|
260
|
+
end
|
261
|
+
|
262
|
+
# Initialize relationships (called after object creation)
|
263
|
+
def initialize_relationships
|
264
|
+
# This can be overridden by subclasses to set up initial relationships
|
265
|
+
end
|
266
|
+
|
267
|
+
# Override save to update relationships
|
268
|
+
def save(update_expiration: true)
|
269
|
+
result = super
|
270
|
+
|
271
|
+
if result && respond_to?(:update_all_indexes)
|
272
|
+
# Update all indexes with current field values
|
273
|
+
update_all_indexes
|
274
|
+
|
275
|
+
# NOTE: Tracking and membership updates are typically done explicitly
|
276
|
+
# since we need to know which specific collections this object should be in
|
277
|
+
end
|
278
|
+
|
279
|
+
result
|
280
|
+
end
|
281
|
+
|
282
|
+
# Override destroy to handle cascade operations
|
283
|
+
def destroy!
|
284
|
+
# Execute cascade operations before destroying the object
|
285
|
+
execute_cascade_operations if respond_to?(:execute_cascade_operations)
|
286
|
+
|
287
|
+
super
|
288
|
+
end
|
289
|
+
|
290
|
+
# Get comprehensive relationship status for this object
|
291
|
+
def relationship_status
|
292
|
+
status = {
|
293
|
+
identifier: identifier,
|
294
|
+
tracking_memberships: [],
|
295
|
+
membership_collections: [],
|
296
|
+
index_memberships: []
|
297
|
+
}
|
298
|
+
|
299
|
+
# Get tracking memberships
|
300
|
+
if respond_to?(:tracking_collections_membership)
|
301
|
+
status[:tracking_memberships] = tracking_collections_membership
|
302
|
+
end
|
303
|
+
|
304
|
+
# Get membership collections
|
305
|
+
status[:membership_collections] = membership_collections if respond_to?(:membership_collections)
|
306
|
+
|
307
|
+
# Get index memberships
|
308
|
+
status[:index_memberships] = indexing_memberships if respond_to?(:indexing_memberships)
|
309
|
+
|
310
|
+
status
|
311
|
+
end
|
312
|
+
|
313
|
+
# Comprehensive cleanup - remove from all relationships
|
314
|
+
def cleanup_all_relationships!
|
315
|
+
# Remove from tracking collections
|
316
|
+
remove_from_all_tracking_collections if respond_to?(:remove_from_all_tracking_collections)
|
317
|
+
|
318
|
+
# Remove from membership collections
|
319
|
+
remove_from_all_memberships if respond_to?(:remove_from_all_memberships)
|
320
|
+
|
321
|
+
# Remove from indexes
|
322
|
+
remove_from_all_indexes if respond_to?(:remove_from_all_indexes)
|
323
|
+
end
|
324
|
+
|
325
|
+
# Dry run for relationship cleanup (preview what would be affected)
|
326
|
+
def cleanup_preview
|
327
|
+
preview = {
|
328
|
+
tracking_collections: [],
|
329
|
+
membership_collections: [],
|
330
|
+
index_entries: []
|
331
|
+
}
|
332
|
+
|
333
|
+
if respond_to?(:cascade_dry_run)
|
334
|
+
cascade_preview = cascade_dry_run
|
335
|
+
preview.merge!(cascade_preview)
|
336
|
+
end
|
337
|
+
|
338
|
+
preview
|
339
|
+
end
|
340
|
+
|
341
|
+
# Validate that this object's relationships are consistent
|
342
|
+
def validate_relationships!
|
343
|
+
errors = []
|
344
|
+
|
345
|
+
# Validate identifier exists
|
346
|
+
errors << 'Object identifier is nil' unless identifier
|
347
|
+
|
348
|
+
# Validate tracking memberships
|
349
|
+
if respond_to?(:tracking_collections_membership)
|
350
|
+
tracking_collections_membership.each do |membership|
|
351
|
+
score = membership[:score]
|
352
|
+
errors << "Invalid score in tracking membership: #{membership}" if score && !score.is_a?(Numeric)
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
raise RelationshipError, "Relationship validation failed for #{self}: #{errors.join('; ')}" if errors.any?
|
357
|
+
|
358
|
+
true
|
359
|
+
end
|
360
|
+
|
361
|
+
# Refresh relationship data from Redis (useful after external changes)
|
362
|
+
def refresh_relationships!
|
363
|
+
# Clear any cached relationship data
|
364
|
+
@relationship_status = nil
|
365
|
+
@tracking_memberships = nil
|
366
|
+
@membership_collections = nil
|
367
|
+
@index_memberships = nil
|
368
|
+
|
369
|
+
# Reload fresh data
|
370
|
+
relationship_status
|
371
|
+
end
|
372
|
+
|
373
|
+
# Create a snapshot of current relationship state (for debugging)
|
374
|
+
def relationship_snapshot
|
375
|
+
{
|
376
|
+
timestamp: Time.now,
|
377
|
+
identifier: identifier,
|
378
|
+
class: self.class.name,
|
379
|
+
status: relationship_status,
|
380
|
+
redis_keys: find_related_redis_keys
|
381
|
+
}
|
382
|
+
end
|
383
|
+
|
384
|
+
# Direct Redis access for instance methods
|
385
|
+
def redis
|
386
|
+
self.class.dbclient
|
387
|
+
end
|
388
|
+
|
389
|
+
# Instance method wrapper for create_temp_key
|
390
|
+
def create_temp_key(base_name, ttl = 300)
|
391
|
+
timestamp = Time.now.to_i
|
392
|
+
random_suffix = SecureRandom.hex(3)
|
393
|
+
temp_key = "temp:#{base_name}:#{timestamp}:#{random_suffix}"
|
394
|
+
|
395
|
+
# Set immediate expiry to ensure cleanup even if operation fails
|
396
|
+
redis.expire(temp_key, ttl)
|
397
|
+
|
398
|
+
temp_key
|
399
|
+
end
|
400
|
+
|
401
|
+
# Instance method wrapper for cleanup_temp_keys
|
402
|
+
def cleanup_temp_keys(pattern = 'temp:*', batch_size = 100)
|
403
|
+
cursor = 0
|
404
|
+
|
405
|
+
loop do
|
406
|
+
cursor, keys = redis.scan(cursor, match: pattern, count: batch_size)
|
407
|
+
|
408
|
+
if keys.any?
|
409
|
+
# Check TTL and remove keys that should have expired
|
410
|
+
keys.each_slice(batch_size) do |key_batch|
|
411
|
+
redis.pipelined do |pipeline|
|
412
|
+
key_batch.each do |key|
|
413
|
+
ttl = redis.ttl(key)
|
414
|
+
pipeline.del(key) if ttl == -1 # Key exists but has no TTL
|
415
|
+
end
|
416
|
+
end
|
417
|
+
end
|
418
|
+
end
|
419
|
+
|
420
|
+
break if cursor.zero?
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
private
|
425
|
+
|
426
|
+
# Find all Redis keys related to this object
|
427
|
+
def find_related_redis_keys
|
428
|
+
related_keys = []
|
429
|
+
id = identifier
|
430
|
+
return related_keys unless id
|
431
|
+
|
432
|
+
# Scan for keys that might contain this object
|
433
|
+
patterns = [
|
434
|
+
'*:*:*', # General pattern for relationship keys
|
435
|
+
"*#{id}*" # Keys containing the identifier
|
436
|
+
]
|
437
|
+
|
438
|
+
patterns.each do |pattern|
|
439
|
+
redis.scan_each(match: pattern, count: 100) do |key|
|
440
|
+
# Check if this key actually contains our object
|
441
|
+
key_type = redis.type(key)
|
442
|
+
|
443
|
+
case key_type
|
444
|
+
when 'zset'
|
445
|
+
related_keys << key if redis.zscore(key, id)
|
446
|
+
when 'set'
|
447
|
+
related_keys << key if redis.sismember(key, id)
|
448
|
+
when 'list'
|
449
|
+
related_keys << key if redis.lpos(key, id)
|
450
|
+
when 'hash'
|
451
|
+
# For hash keys, check if any field values match our identifier
|
452
|
+
hash_values = redis.hvals(key)
|
453
|
+
related_keys << key if hash_values.include?(id.to_s)
|
454
|
+
end
|
455
|
+
end
|
456
|
+
end
|
457
|
+
|
458
|
+
related_keys.uniq
|
459
|
+
end
|
460
|
+
end
|
461
|
+
|
462
|
+
# Register the feature with Familia
|
463
|
+
Familia::Base.add_feature Relationships, :relationships
|
464
|
+
end
|
465
|
+
end
|
466
|
+
end
|
@@ -54,7 +54,7 @@ module Familia::Features
|
|
54
54
|
@safe_dump_field_map = {}
|
55
55
|
|
56
56
|
def self.included(base)
|
57
|
-
Familia.
|
57
|
+
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
58
58
|
base.extend ClassMethods
|
59
59
|
|
60
60
|
# Optionally define safe_dump_fields in the class to make
|
@@ -141,7 +141,7 @@ class RedactedString
|
|
141
141
|
def inspect = to_s
|
142
142
|
def cleared? = @cleared
|
143
143
|
|
144
|
-
# Returns true when it's literally the same object,
|
144
|
+
# Returns true when it's literally the same object, otherwise false.
|
145
145
|
# This prevents timing attacks where an attacker could potentially
|
146
146
|
# infer information about the secret value through comparison timing
|
147
147
|
def ==(other)
|