familia 2.0.0.pre6 → 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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -13
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +2 -2
  9. data/docs/wiki/Feature-System-Guide.md +36 -5
  10. data/docs/wiki/Home.md +30 -20
  11. data/docs/wiki/Relationships-Guide.md +684 -0
  12. data/examples/bit_encoding_integration.rb +237 -0
  13. data/examples/redis_command_validation_example.rb +231 -0
  14. data/examples/relationships_basic.rb +273 -0
  15. data/lib/familia/connection.rb +3 -3
  16. data/lib/familia/data_type.rb +7 -4
  17. data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
  18. data/lib/familia/features/encrypted_fields.rb +413 -4
  19. data/lib/familia/features/expiration.rb +319 -33
  20. data/lib/familia/features/quantization.rb +385 -44
  21. data/lib/familia/features/relationships/cascading.rb +438 -0
  22. data/lib/familia/features/relationships/indexing.rb +370 -0
  23. data/lib/familia/features/relationships/membership.rb +503 -0
  24. data/lib/familia/features/relationships/permission_management.rb +264 -0
  25. data/lib/familia/features/relationships/querying.rb +620 -0
  26. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  27. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  28. data/lib/familia/features/relationships/tracking.rb +379 -0
  29. data/lib/familia/features/relationships.rb +466 -0
  30. data/lib/familia/features/transient_fields.rb +192 -10
  31. data/lib/familia/features.rb +2 -1
  32. data/lib/familia/horreum/subclass/definition.rb +1 -1
  33. data/lib/familia/validation/command_recorder.rb +336 -0
  34. data/lib/familia/validation/expectations.rb +519 -0
  35. data/lib/familia/validation/test_helpers.rb +443 -0
  36. data/lib/familia/validation/validator.rb +412 -0
  37. data/lib/familia/validation.rb +140 -0
  38. data/lib/familia/version.rb +1 -1
  39. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  40. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  41. data/try/edge_cases/string_coercion_try.rb +2 -0
  42. data/try/encryption/encryption_core_try.rb +3 -1
  43. data/try/features/categorical_permissions_try.rb +515 -0
  44. data/try/features/encryption_fields/concealed_string_core_try.rb +3 -0
  45. data/try/features/encryption_fields/context_isolation_try.rb +1 -0
  46. data/try/features/relationships_edge_cases_try.rb +145 -0
  47. data/try/features/relationships_performance_minimal_try.rb +132 -0
  48. data/try/features/relationships_performance_simple_try.rb +155 -0
  49. data/try/features/relationships_performance_try.rb +420 -0
  50. data/try/features/relationships_performance_working_try.rb +144 -0
  51. data/try/features/relationships_try.rb +237 -0
  52. data/try/features/safe_dump_try.rb +3 -0
  53. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  54. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  55. data/try/helpers/test_helpers.rb +1 -1
  56. data/try/horreum/base_try.rb +14 -8
  57. data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
  58. data/try/horreum/relations_try.rb +1 -1
  59. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  60. data/try/validation/command_validation_try.rb.disabled +207 -0
  61. data/try/validation/performance_validation_try.rb.disabled +324 -0
  62. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  63. metadata +32 -4
  64. data/docs/wiki/RelatableObjects-Guide.md +0 -563
  65. data/lib/familia/features/relatable_objects.rb +0 -125
  66. data/try/features/relatable_objects_try.rb +0 -220
@@ -0,0 +1,412 @@
1
+ # lib/familia/validation/validator.rb
2
+
3
+ module Familia
4
+ module Validation
5
+ # Main validation engine that orchestrates command recording and validation.
6
+ # Provides high-level interface for validating Redis operations against
7
+ # expectations with detailed reporting and atomicity verification.
8
+ #
9
+ # @example Basic validation
10
+ # validator = Validator.new
11
+ #
12
+ # result = validator.validate do |expect|
13
+ # # Define expectations
14
+ # expect.hset("user:123", "name", "John")
15
+ # .incr("counter")
16
+ #
17
+ # # Execute code under test
18
+ # user = User.new(id: "123", name: "John")
19
+ # user.save
20
+ # Counter.increment
21
+ # end
22
+ #
23
+ # puts result.valid? ? "PASS" : "FAIL"
24
+ # puts result.detailed_report
25
+ #
26
+ # @example Transaction validation
27
+ # validator = Validator.new
28
+ #
29
+ # result = validator.validate do |expect|
30
+ # expect.transaction do |tx|
31
+ # tx.hset("user:123", "name", "John")
32
+ # .incr("counter")
33
+ # end
34
+ #
35
+ # # Code should execute atomically
36
+ # Familia.transaction do |conn|
37
+ # conn.hset("user:123", "name", "John")
38
+ # conn.incr("counter")
39
+ # end
40
+ # end
41
+ #
42
+ class Validator
43
+ attr_reader :options
44
+
45
+ def initialize(options = {})
46
+ @options = {
47
+ auto_register_middleware: true,
48
+ strict_atomicity: true,
49
+ performance_tracking: true,
50
+ command_filtering: :all # :all, :familia_only, :custom
51
+ }.merge(options)
52
+
53
+ register_middleware if @options[:auto_register_middleware]
54
+ end
55
+
56
+ # Main validation method - records commands and validates against expectations
57
+ def validate(&block)
58
+ raise ArgumentError, "Block required for validation" unless block_given?
59
+
60
+ expectations = nil
61
+ command_sequence = nil
62
+
63
+ begin
64
+ # Start recording commands
65
+ CommandRecorder.start_recording
66
+ register_middleware_if_needed
67
+
68
+ # Execute the validation block
69
+ expectations = CommandExpectations.new
70
+ block.call(expectations)
71
+
72
+ # Get recorded commands
73
+ command_sequence = CommandRecorder.stop_recording
74
+
75
+ rescue => e
76
+ CommandRecorder.stop_recording
77
+ raise ValidationError, "Validation failed with error: #{e.message}"
78
+ end
79
+
80
+ # Validate and return result
81
+ result = expectations.validate(command_sequence)
82
+
83
+ if @options[:performance_tracking]
84
+ add_performance_metrics(result, command_sequence)
85
+ end
86
+
87
+ if @options[:strict_atomicity]
88
+ apply_atomicity_validation(result, command_sequence)
89
+ end
90
+
91
+ result
92
+ end
93
+
94
+ # Validate that specific code executes expected Redis commands
95
+ def validate_execution(expectations_block, execution_block)
96
+ expectations = CommandExpectations.new
97
+ expectations_block.call(expectations)
98
+
99
+ CommandRecorder.start_recording
100
+ register_middleware_if_needed
101
+
102
+ begin
103
+ execution_block.call
104
+ command_sequence = CommandRecorder.stop_recording
105
+ rescue => e
106
+ CommandRecorder.stop_recording
107
+ raise ValidationError, "Execution failed: #{e.message}"
108
+ end
109
+
110
+ result = expectations.validate(command_sequence)
111
+
112
+ if @options[:performance_tracking]
113
+ add_performance_metrics(result, command_sequence)
114
+ end
115
+
116
+ result
117
+ end
118
+
119
+ # Validate that code executes atomically (within transactions)
120
+ def validate_atomicity(&block)
121
+ CommandRecorder.start_recording
122
+ register_middleware_if_needed
123
+
124
+ begin
125
+ block.call
126
+ command_sequence = CommandRecorder.stop_recording
127
+ rescue => e
128
+ CommandRecorder.stop_recording
129
+ raise ValidationError, "Atomicity validation failed: #{e.message}"
130
+ end
131
+
132
+ AtomicityValidator.new(command_sequence, @options).validate
133
+ end
134
+
135
+ # Assert that specific commands were executed
136
+ def assert_commands_executed(expected_commands, actual_commands = nil)
137
+ actual_commands ||= get_last_recorded_sequence
138
+
139
+ expectations = CommandExpectations.new
140
+ expected_commands.each do |cmd_spec|
141
+ case cmd_spec
142
+ when Array
143
+ expectations.command(cmd_spec[0], *cmd_spec[1..-1])
144
+ when Hash
145
+ cmd_spec.each do |cmd, args|
146
+ expectations.command(cmd, *Array(args))
147
+ end
148
+ when String
149
+ expectations.match_pattern(cmd_spec)
150
+ end
151
+ end
152
+
153
+ expectations.validate(actual_commands)
154
+ end
155
+
156
+ # Performance analysis of recorded commands
157
+ def analyze_performance(command_sequence)
158
+ PerformanceAnalyzer.new(command_sequence).analyze
159
+ end
160
+
161
+ # Capture and return Redis commands without validation
162
+ def capture_redis_commands(&block)
163
+ CommandRecorder.start_recording
164
+ register_middleware_if_needed
165
+ block.call if block_given?
166
+ CommandRecorder.stop_recording
167
+ end
168
+
169
+ private
170
+
171
+ def register_middleware
172
+ return if @middleware_registered
173
+
174
+ # Register our command recording middleware using the same pattern as connection.rb
175
+ if defined?(RedisClient)
176
+ RedisClient.register(CommandRecorder::Middleware)
177
+ end
178
+ @middleware_registered = true
179
+ end
180
+
181
+ def register_middleware_if_needed
182
+ register_middleware unless @middleware_registered
183
+ end
184
+
185
+ def get_last_recorded_sequence
186
+ CommandRecorder.current_sequence
187
+ end
188
+
189
+ def add_performance_metrics(result, command_sequence)
190
+ analyzer = PerformanceAnalyzer.new(command_sequence)
191
+ metrics = analyzer.analyze
192
+
193
+ result.instance_variable_set(:@performance_metrics, metrics)
194
+
195
+ # Add singleton methods to result
196
+ result.define_singleton_method(:performance_metrics) { @performance_metrics }
197
+ result.define_singleton_method(:total_duration_ms) { @performance_metrics[:total_duration_ms] }
198
+ result.define_singleton_method(:command_efficiency) { @performance_metrics[:efficiency_score] }
199
+ end
200
+
201
+ def apply_atomicity_validation(result, command_sequence)
202
+ atomicity_validator = AtomicityValidator.new(command_sequence, @options)
203
+ atomicity_result = atomicity_validator.validate
204
+
205
+ unless atomicity_result.valid?
206
+ result.instance_variable_get(:@errors).concat(atomicity_result.error_messages)
207
+ result.instance_variable_get(:@warnings).concat(atomicity_result.warning_messages)
208
+ end
209
+ end
210
+ end
211
+
212
+ # Validates that operations that should be atomic actually execute within transactions
213
+ class AtomicityValidator
214
+ attr_reader :command_sequence, :options
215
+
216
+ def initialize(command_sequence, options = {})
217
+ @command_sequence = command_sequence
218
+ @options = options
219
+ @errors = []
220
+ @warnings = []
221
+ end
222
+
223
+ def validate
224
+ check_transaction_boundaries
225
+ check_orphaned_commands
226
+ check_nested_transactions
227
+
228
+ ValidationResult.new(nil, @command_sequence).tap do |result|
229
+ result.instance_variable_set(:@errors, @errors)
230
+ result.instance_variable_set(:@warnings, @warnings)
231
+ result.instance_variable_set(:@valid, @errors.empty?)
232
+ end
233
+ end
234
+
235
+ private
236
+
237
+ def check_transaction_boundaries
238
+ @command_sequence.transaction_blocks.each_with_index do |tx_block, i|
239
+ unless tx_block.valid?
240
+ @errors << "Transaction block #{i + 1} is invalid (missing MULTI or EXEC)"
241
+ end
242
+
243
+ if tx_block.command_count == 0
244
+ @warnings << "Transaction block #{i + 1} contains no commands"
245
+ end
246
+ end
247
+ end
248
+
249
+ def check_orphaned_commands
250
+ # Commands that should be in transactions but aren't
251
+ orphaned_commands = @command_sequence.commands.select do |cmd|
252
+ !cmd.atomic_command? && should_be_atomic?(cmd)
253
+ end
254
+
255
+ orphaned_commands.each do |cmd|
256
+ @warnings << "Command #{cmd} should be executed atomically but was not in a transaction"
257
+ end
258
+ end
259
+
260
+ def check_nested_transactions
261
+ transaction_depth = 0
262
+
263
+ @command_sequence.commands.each do |cmd|
264
+ case cmd.command
265
+ when 'MULTI'
266
+ transaction_depth += 1
267
+ if transaction_depth > 1
268
+ @errors << "Nested transactions detected - Redis does not support nested MULTI/EXEC"
269
+ end
270
+ when 'EXEC', 'DISCARD'
271
+ transaction_depth -= 1
272
+ if transaction_depth < 0
273
+ @errors << "EXEC/DISCARD without matching MULTI command"
274
+ end
275
+ end
276
+ end
277
+
278
+ if transaction_depth > 0
279
+ @errors << "Unclosed transaction detected - MULTI without matching EXEC/DISCARD"
280
+ end
281
+ end
282
+
283
+ def should_be_atomic?(cmd)
284
+ # Define patterns for commands that should typically be atomic
285
+ atomic_patterns = [
286
+ /^HSET.*counter/i, # Counter updates
287
+ /^INCR/i, # Increments
288
+ /^DECR/i, # Decrements
289
+ /batch_update/i # Batch operations
290
+ ]
291
+
292
+ atomic_patterns.any? { |pattern| cmd.to_s.match?(pattern) }
293
+ end
294
+ end
295
+
296
+ # Analyzes performance characteristics of recorded commands
297
+ class PerformanceAnalyzer
298
+ attr_reader :command_sequence
299
+
300
+ def initialize(command_sequence)
301
+ @command_sequence = command_sequence
302
+ end
303
+
304
+ def analyze
305
+ {
306
+ total_commands: @command_sequence.command_count,
307
+ total_duration_ms: total_duration_ms,
308
+ average_command_time_us: average_command_time,
309
+ slowest_commands: slowest_commands(5),
310
+ command_type_breakdown: command_type_breakdown,
311
+ transaction_efficiency: transaction_efficiency,
312
+ potential_n_plus_one: detect_n_plus_one_patterns,
313
+ efficiency_score: calculate_efficiency_score
314
+ }
315
+ end
316
+
317
+ private
318
+
319
+ def total_duration_ms
320
+ @command_sequence.commands.sum(&:duration_us) / 1000.0
321
+ end
322
+
323
+ def average_command_time
324
+ return 0 if @command_sequence.command_count == 0
325
+
326
+ total_time = @command_sequence.commands.sum(&:duration_us)
327
+ total_time / @command_sequence.command_count
328
+ end
329
+
330
+ def slowest_commands(limit = 5)
331
+ @command_sequence.commands
332
+ .sort_by(&:duration_us)
333
+ .reverse
334
+ .first(limit)
335
+ .map { |cmd| { command: cmd.to_s, duration_us: cmd.duration_us } }
336
+ end
337
+
338
+ def command_type_breakdown
339
+ @command_sequence.commands
340
+ .group_by(&:command_type)
341
+ .transform_values(&:count)
342
+ end
343
+
344
+ def transaction_efficiency
345
+ return { score: 1.0, details: "No transactions" } if @command_sequence.transaction_count == 0
346
+
347
+ total_tx_commands = @command_sequence.transaction_blocks.sum(&:command_count)
348
+ total_commands = @command_sequence.command_count
349
+ tx_overhead = @command_sequence.transaction_count * 2 # MULTI + EXEC
350
+
351
+ efficiency = total_tx_commands.to_f / (total_tx_commands + tx_overhead)
352
+
353
+ {
354
+ score: efficiency,
355
+ total_transaction_commands: total_tx_commands,
356
+ transaction_overhead: tx_overhead,
357
+ details: "#{@command_sequence.transaction_count} transactions with #{total_tx_commands} commands"
358
+ }
359
+ end
360
+
361
+ def detect_n_plus_one_patterns
362
+ patterns = []
363
+
364
+ # Look for repeated similar commands that could be batched
365
+ command_groups = @command_sequence.commands.group_by { |cmd| cmd.command }
366
+
367
+ command_groups.each do |command, commands|
368
+ next unless commands.length > 3 # Threshold for N+1 detection
369
+
370
+ # Check if commands are similar (same command, similar keys)
371
+ if similar_commands?(commands)
372
+ patterns << {
373
+ command: command,
374
+ count: commands.length,
375
+ suggestion: "Consider batching #{command} operations"
376
+ }
377
+ end
378
+ end
379
+
380
+ patterns
381
+ end
382
+
383
+ def similar_commands?(commands)
384
+ return false if commands.length < 2
385
+
386
+ first_cmd = commands.first
387
+ commands.drop(1).all? do |cmd|
388
+ cmd.command == first_cmd.command &&
389
+ cmd.args.length == first_cmd.args.length
390
+ end
391
+ end
392
+
393
+ def calculate_efficiency_score
394
+ base_score = 100.0
395
+
396
+ # Penalize for potential N+1 patterns
397
+ n_plus_one_penalty = detect_n_plus_one_patterns.sum { |pattern| pattern[:count] * 2 }
398
+
399
+ # Penalize for non-atomic operations that should be atomic
400
+ non_atomic_penalty = @command_sequence.commands.count { |cmd| !cmd.atomic_command? } * 1
401
+
402
+ # Bonus for using transactions appropriately
403
+ transaction_bonus = @command_sequence.transaction_count > 0 ? 10 : 0
404
+
405
+ [base_score - n_plus_one_penalty - non_atomic_penalty + transaction_bonus, 0].max
406
+ end
407
+ end
408
+
409
+ # Custom error class for validation failures
410
+ class ValidationError < StandardError; end
411
+ end
412
+ end
@@ -0,0 +1,140 @@
1
+ # lib/familia/validation.rb
2
+
3
+ # Redis Command Validation Framework for Familia
4
+ #
5
+ # Provides comprehensive validation of Redis operations to ensure commands
6
+ # execute exactly as expected, with particular focus on atomicity verification
7
+ # for transaction-based operations.
8
+ #
9
+ # @example Basic command validation
10
+ # include Familia::Validation
11
+ #
12
+ # validator = Validator.new
13
+ # result = validator.validate do |expect|
14
+ # expect.hset("user:123", "name", "John")
15
+ # .incr("counter")
16
+ #
17
+ # # Execute code that should perform these operations
18
+ # user = User.new(id: "123", name: "John")
19
+ # user.save
20
+ # Counter.increment
21
+ # end
22
+ #
23
+ # puts result.valid? ? "PASS" : "FAIL"
24
+ #
25
+ # @example Using test helpers in try files
26
+ # require_relative 'lib/familia/validation'
27
+ # extend Familia::Validation::TestHelpers
28
+ #
29
+ # ## User save executes expected Redis commands
30
+ # user = TestUser.new(id: "123", name: "John")
31
+ # assert_redis_commands do |expect|
32
+ # expect.hset("testuser:123:object", "name", "John")
33
+ # user.save
34
+ # end
35
+ # #=> true
36
+ #
37
+ # @example Transaction atomicity validation
38
+ # extend Familia::Validation::TestHelpers
39
+ #
40
+ # ## Transfer should be atomic
41
+ # assert_atomic_operation do |expect|
42
+ # expect.transaction do |tx|
43
+ # tx.hset("account:123", "balance", "500")
44
+ # .hset("account:456", "balance", "1500")
45
+ # end
46
+ #
47
+ # transfer_funds(from: "123", to: "456", amount: 500)
48
+ # end
49
+ # #=> true
50
+
51
+ require_relative 'validation/command_recorder'
52
+ require_relative 'validation/expectations'
53
+ require_relative 'validation/validator'
54
+ require_relative 'validation/test_helpers'
55
+
56
+ module Familia
57
+ module Validation
58
+ # Quick access to main validator
59
+ def self.validator(options = {})
60
+ Validator.new(options)
61
+ end
62
+
63
+ # Validate a block of code against expected Redis commands
64
+ def self.validate(options = {}, &block)
65
+ validator(options).validate(&block)
66
+ end
67
+
68
+ # Validate that code executes atomically
69
+ def self.validate_atomicity(options = {}, &block)
70
+ validator(options).validate_atomicity(&block)
71
+ end
72
+
73
+ # Capture Redis commands without validation
74
+ def self.capture_commands(&block)
75
+ CommandRecorder.start_recording
76
+ block.call if block_given?
77
+ CommandRecorder.stop_recording
78
+ end
79
+
80
+ # Quick performance analysis
81
+ def self.analyze_performance(&block)
82
+ commands = capture_commands(&block)
83
+ PerformanceAnalyzer.new(commands).analyze
84
+ end
85
+
86
+ # Register validation middleware with Redis client
87
+ def self.register_middleware!
88
+ if defined?(RedisClient)
89
+ RedisClient.register(CommandRecorder::Middleware)
90
+ else
91
+ warn "RedisClient not available - command recording will not work"
92
+ end
93
+ end
94
+
95
+ # Configuration for validation framework
96
+ module Config
97
+ # Default options for validators
98
+ DEFAULT_OPTIONS = {
99
+ auto_register_middleware: true,
100
+ strict_atomicity: true,
101
+ performance_tracking: true,
102
+ command_filtering: :all
103
+ }.freeze
104
+
105
+ @options = DEFAULT_OPTIONS.dup
106
+
107
+ class << self
108
+ attr_accessor :options
109
+
110
+ def configure
111
+ yield self if block_given?
112
+ end
113
+
114
+ def reset!
115
+ @options = DEFAULT_OPTIONS.dup
116
+ end
117
+
118
+ # Option accessors
119
+ def auto_register_middleware?
120
+ @options[:auto_register_middleware]
121
+ end
122
+
123
+ def strict_atomicity?
124
+ @options[:strict_atomicity]
125
+ end
126
+
127
+ def performance_tracking?
128
+ @options[:performance_tracking]
129
+ end
130
+
131
+ def command_filtering
132
+ @options[:command_filtering]
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ # Auto-register middleware if enabled
140
+ Familia::Validation.register_middleware! if Familia::Validation::Config.auto_register_middleware?
@@ -3,6 +3,6 @@
3
3
  module Familia
4
4
  # Version information for the Familia
5
5
  unless defined?(Familia::VERSION)
6
- VERSION = '2.0.0.pre6'
6
+ VERSION = '2.0.0.pre7'
7
7
  end
8
8
  end
@@ -61,6 +61,7 @@ end
61
61
  @symbol_result[:name]
62
62
  #=> "John"
63
63
 
64
+ ## String keys also work correctly
64
65
  @string_result['name']
65
66
  #=> "John"
66
67
 
@@ -6,6 +6,7 @@ require_relative '../helpers/test_helpers'
6
6
  TestClass = Class.new(Familia::Horreum) do
7
7
  identifier_field :email
8
8
  field :email
9
+ field :ttl # This should cause an error
9
10
  field :default_expiration
10
11
  end
11
12
 
@@ -39,6 +39,7 @@ end
39
39
  #=:> BadIdentifierTest
40
40
 
41
41
  # Test polymorphic string usage for Familia objects
42
+ ## Customer creation with string coercion
42
43
  @customer = Customer.new(@customer_id)
43
44
  @customer.name = 'John Doe'
44
45
  @customer.planid = 'premium'
@@ -86,6 +87,7 @@ session
86
87
  @session.to_s
87
88
  #=<> @session_id
88
89
 
90
+ ## Test lookup by ID functionality
89
91
  lookup_by_id(@customer)
90
92
  #=> @customer_id.upcase
91
93
 
@@ -40,7 +40,9 @@ Familia.config.current_key_version = :v2
40
40
  encrypted = Familia::Encryption.encrypt(plaintext, context: context)
41
41
  encrypted_data = JSON.parse(encrypted, symbolize_names: true)
42
42
  encrypted_data[:key_version]
43
- #=> "v2"## Nonce is unique - same plaintext encrypts to different ciphertext
43
+ #=> "v2"
44
+
45
+ ## Nonce is unique - same plaintext encrypts to different ciphertext
44
46
  test_keys = { v1: Base64.strict_encode64('a' * 32), v2: Base64.strict_encode64('b' * 32) }
45
47
  context = "TestModel:secret_field:user123"
46
48
  plaintext = "sensitive data here"