familia 2.3.0 → 2.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 217726d80e8424a761dfa3aa0f565e9cdadd3da409654f5077226e1a85ae29ac
4
- data.tar.gz: b9ce4a30717b220b78689332da5fe40fe790535029399fa2b0f9978dabd89ff5
3
+ metadata.gz: 87748023c0bec120fe0b4936eac184ebe7f72651639b7ff87238782987654cc7
4
+ data.tar.gz: 5a3cbaf9000cf1e12cdcff9f2d35eecca57a010725e501de37485bda7cf214af
5
5
  SHA512:
6
- metadata.gz: ba13b9a6832ffce17a17548a398ca25f5588b6b7bc24e349151ddb3289173418947f833959fa83497e65d953ce781c51007c8a8516c42a5deab589e2e013100b
7
- data.tar.gz: a2165e1aa07cdd4f7ca90f6c233dace66f9aba8647f652fb26f8ae484ee142506c218a57c57f1c9d86dc5a551958cd95a01be28de789b817ebd529280ee6c8f3
6
+ metadata.gz: 21847a2154326c84d70557ee62ecfc4ea960f74ae6358e41b6c1d6fb7012d58034256a35dbc9aae26778a179cc3524e54012933a84191d2dc570f9d73c400cb8
7
+ data.tar.gz: da077f52d0ecbeba97779470f1e6d3a51934e7e7e3e03b05005e1187794f3ef23c9386aa15fe3747113c31982bf6e56add41948bd78a0d6d2867abaef883a31b
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,43 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
7
7
 
8
8
  <!--scriv-insert-here-->
9
9
 
10
+ .. _changelog-2.3.1:
11
+
12
+ 2.3.1 — 2026-03-06
13
+ ==================
14
+
15
+ Fixed
16
+ -----
17
+
18
+ - Objects loaded from Redis via ``load``, ``find``, ``find_by_id``, ``find_by_dbkey``,
19
+ and ``load_multi`` no longer appear dirty. The ``instantiate_from_hash`` factory
20
+ now calls ``clear_dirty!`` after field assignment, matching the behavior of
21
+ ``initialize`` and ``refresh!``. Previously, every loaded object had all fields
22
+ marked dirty, causing false ``warn_if_dirty!`` warnings on subsequent collection
23
+ writes. Fixes `#225 <https://github.com/delano/familia/issues/225>`_.
24
+
25
+ - Added ``warn_if_dirty!`` to 14 secondary collection mutation methods that were
26
+ missing the write-order check: ``remove_element``, ``pop``, ``move`` (UnsortedSet);
27
+ ``remove_element``, ``remrangebyrank``, ``remrangebyscore`` (SortedSet); ``pop``,
28
+ ``shift``, ``remove_element`` (ListKey); ``hsetnx``, ``remove_field``,
29
+ ``update``/``merge!`` (HashKey); ``value=``, ``setnx`` (JsonStringKey). Counter and
30
+ increment methods are intentionally excluded as they operate independently of the
31
+ parent's scalar lifecycle.
32
+
33
+ - ``batch_update`` and ``batch_fast_write`` now update in-memory field values only
34
+ after the MULTI/EXEC transaction succeeds. Previously, setters ran inside the
35
+ transaction block, so a failed transaction could leave the object's in-memory
36
+ state diverged from Redis.
37
+
38
+ AI Assistance
39
+ -------------
40
+
41
+ - Claude identified the one-line fix in ``instantiate_from_hash``, audited all
42
+ collection mutation paths for missing ``warn_if_dirty!`` calls, and triaged
43
+ the 29 candidates into tiers based on write-order risk. Also caught the
44
+ transaction-safety issue in ``batch_update``/``batch_fast_write`` during the
45
+ broader audit.
46
+
10
47
  .. _changelog-2.3.0:
11
48
 
12
49
  2.3.0 — 2026-02-26
data/Gemfile CHANGED
@@ -5,7 +5,7 @@ source 'https://rubygems.org'
5
5
  gemspec
6
6
 
7
7
  group :test do
8
- gem 'concurrent-ruby', '~> 1.3.5', require: false
8
+ gem 'concurrent-ruby', '~> 1.3.6', require: false
9
9
  gem 'ruby-prof'
10
10
  gem 'stackprof'
11
11
  gem 'timecop', require: false
@@ -15,12 +15,12 @@ end
15
15
  group :development, :test do
16
16
  gem 'benchmark', '~> 0.4', require: false
17
17
  gem 'debug', require: false
18
+ gem 'irb', '~> 1.15.2', require: false
18
19
  gem 'json_schemer', '~> 2.0', require: false
19
20
  gem 'rake', '~> 13.0', require: false
20
- gem 'irb', '~> 1.15.2', require: false
21
21
  gem 'redcarpet', require: false
22
22
  gem 'reek', require: false
23
- gem 'rubocop', '~> 1.81.1', require: false
23
+ gem 'rubocop', '~> 1.85.1', require: false
24
24
  gem 'rubocop-performance', require: false
25
25
  gem 'rubocop-thread_safety', require: false
26
26
  gem 'ruby-lsp', require: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.3.0)
4
+ familia (2.3.1)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -14,6 +14,8 @@ PATH
14
14
  GEM
15
15
  remote: https://rubygems.org/
16
16
  specs:
17
+ addressable (2.8.9)
18
+ public_suffix (>= 2.0.2, < 8.0)
17
19
  ast (2.4.3)
18
20
  base64 (0.3.0)
19
21
  benchmark (0.5.0)
@@ -65,6 +67,9 @@ GEM
65
67
  rdoc (>= 4.0.0)
66
68
  reline (>= 0.4.2)
67
69
  json (2.15.1)
70
+ json-schema (6.2.0)
71
+ addressable (~> 2.8)
72
+ bigdecimal (>= 3.1, < 5)
68
73
  json_schemer (2.5.0)
69
74
  bigdecimal
70
75
  hana (~> 1.3)
@@ -73,6 +78,8 @@ GEM
73
78
  language_server-protocol (3.17.0.5)
74
79
  lint_roller (1.1.0)
75
80
  logger (1.7.0)
81
+ mcp (0.8.0)
82
+ json-schema (>= 4.1)
76
83
  minitest (5.26.0)
77
84
  oj (3.16.13)
78
85
  bigdecimal (>= 3.0)
@@ -87,10 +94,11 @@ GEM
87
94
  pp (0.6.3)
88
95
  prettyprint
89
96
  prettyprint (0.2.0)
90
- prism (1.6.0)
97
+ prism (1.9.0)
91
98
  psych (5.2.6)
92
99
  date
93
100
  stringio
101
+ public_suffix (7.0.5)
94
102
  racc (1.8.1)
95
103
  rainbow (3.1.1)
96
104
  rake (13.3.1)
@@ -130,20 +138,21 @@ GEM
130
138
  diff-lcs (>= 1.2.0, < 2.0)
131
139
  rspec-support (~> 3.13.0)
132
140
  rspec-support (3.13.6)
133
- rubocop (1.81.1)
141
+ rubocop (1.85.1)
134
142
  json (~> 2.3)
135
143
  language_server-protocol (~> 3.17.0.2)
136
144
  lint_roller (~> 1.1.0)
145
+ mcp (~> 0.6)
137
146
  parallel (~> 1.10)
138
147
  parser (>= 3.3.0.2)
139
148
  rainbow (>= 2.2.2, < 4.0)
140
149
  regexp_parser (>= 2.9.3, < 3.0)
141
- rubocop-ast (>= 1.47.1, < 2.0)
150
+ rubocop-ast (>= 1.49.0, < 2.0)
142
151
  ruby-progressbar (~> 1.7)
143
152
  unicode-display_width (>= 2.4.0, < 4.0)
144
- rubocop-ast (1.47.1)
153
+ rubocop-ast (1.49.0)
145
154
  parser (>= 3.3.7.2)
146
- prism (~> 1.4)
155
+ prism (~> 1.7)
147
156
  rubocop-performance (1.25.0)
148
157
  lint_roller (~> 1.1)
149
158
  rubocop (>= 1.75.0, < 2.0)
@@ -189,7 +198,7 @@ PLATFORMS
189
198
 
190
199
  DEPENDENCIES
191
200
  benchmark (~> 0.4)
192
- concurrent-ruby (~> 1.3.5)
201
+ concurrent-ruby (~> 1.3.6)
193
202
  debug
194
203
  familia!
195
204
  irb (~> 1.15.2)
@@ -198,7 +207,7 @@ DEPENDENCIES
198
207
  rbnacl (~> 7.1, >= 7.1.1)
199
208
  redcarpet
200
209
  reek
201
- rubocop (~> 1.81.1)
210
+ rubocop (~> 1.85.1)
202
211
  rubocop-performance
203
212
  rubocop-thread_safety
204
213
  ruby-lsp
@@ -166,7 +166,7 @@ module Familia
166
166
  begin
167
167
  Familia::JsonSerializer.parse(val)
168
168
  rescue Familia::SerializerError
169
- Familia.debug "[deserialize] Raw fallback in #{dbkey}: #{val.inspect[0..80]}"
169
+ Familia.warn "[deserialize] Raw fallback in #{dbkey} (#{val.class}, #{val.respond_to?(:bytesize) ? val.bytesize : '?'} bytes)"
170
170
  val
171
171
  end
172
172
  end
@@ -77,6 +77,7 @@ module Familia
77
77
  # @param val [Object] The value to set
78
78
  # @return [Integer] 1 if field is a new field and value was set, 0 if field already exists
79
79
  def hsetnx(field, val)
80
+ warn_if_dirty!
80
81
  ret = dbclient.hsetnx dbkey, field.to_s, serialize_value(val)
81
82
  update_expiration if ret == 1
82
83
  ret
@@ -100,6 +101,7 @@ module Familia
100
101
  # @param field [String] The field to remove
101
102
  # @return [Integer] The number of fields that were removed (0 or 1)
102
103
  def remove_field(field)
104
+ warn_if_dirty!
103
105
  ret = dbclient.hdel dbkey, field.to_s
104
106
  update_expiration
105
107
  ret
@@ -122,6 +124,7 @@ module Familia
122
124
  alias decrby decrement
123
125
 
124
126
  def update(hsh = {})
127
+ warn_if_dirty!
125
128
  raise ArgumentError, 'Argument to bulk_set must be a hash' unless hsh.is_a?(Hash)
126
129
 
127
130
  data = hsh.inject([]) { |ret, pair| ret << [pair[0], serialize_value(pair[1])] }.flatten
@@ -80,6 +80,7 @@ module Familia
80
80
  # @return [String] "OK" on success
81
81
  #
82
82
  def value=(val)
83
+ warn_if_dirty!
83
84
  ret = dbclient.set(dbkey, serialize_value(val))
84
85
  update_expiration
85
86
  ret
@@ -93,6 +94,7 @@ module Familia
93
94
  # @return [Boolean] true if the key was set, false if it already existed
94
95
  #
95
96
  def setnx(val)
97
+ warn_if_dirty!
96
98
  ret = dbclient.setnx(dbkey, serialize_value(val))
97
99
  update_expiration if ret
98
100
  ret
@@ -50,12 +50,14 @@ module Familia
50
50
  alias prepend unshift
51
51
 
52
52
  def pop
53
+ warn_if_dirty!
53
54
  ret = deserialize_value dbclient.rpop(dbkey)
54
55
  update_expiration
55
56
  ret
56
57
  end
57
58
 
58
59
  def shift
60
+ warn_if_dirty!
59
61
  ret = deserialize_value dbclient.lpop(dbkey)
60
62
  update_expiration
61
63
  ret
@@ -85,6 +87,7 @@ module Familia
85
87
  # @param count [Integer] Number of elements to remove (0 means all)
86
88
  # @return [Integer] The number of removed elements
87
89
  def remove_element(value, count = 0)
90
+ warn_if_dirty!
88
91
  ret = dbclient.lrem dbkey, count, serialize_value(value)
89
92
  update_expiration
90
93
  ret
@@ -266,12 +266,14 @@ module Familia
266
266
  end
267
267
 
268
268
  def remrangebyrank(srank, erank)
269
+ warn_if_dirty!
269
270
  ret = dbclient.zremrangebyrank dbkey, srank, erank
270
271
  update_expiration
271
272
  ret
272
273
  end
273
274
 
274
275
  def remrangebyscore(sscore, escore)
276
+ warn_if_dirty!
275
277
  ret = dbclient.zremrangebyscore dbkey, sscore, escore
276
278
  update_expiration
277
279
  ret
@@ -295,6 +297,7 @@ module Familia
295
297
  # @param value The value to remove from the sorted set
296
298
  # @return [Integer] The number of members that were removed (0 or 1)
297
299
  def remove_element(value)
300
+ warn_if_dirty!
298
301
  Familia.trace :REMOVE_ELEMENT, nil, "#{value}<#{value.class}>" if Familia.debug?
299
302
  ret = dbclient.zrem dbkey, serialize_value(value)
300
303
  update_expiration
@@ -87,6 +87,7 @@ module Familia
87
87
  # @param value The value to remove from the set
88
88
  # @return [Integer] The number of members that were removed (0 or 1)
89
89
  def remove_element(value)
90
+ warn_if_dirty!
90
91
  ret = dbclient.srem dbkey, serialize_value(value)
91
92
  update_expiration
92
93
  ret
@@ -98,12 +99,14 @@ module Familia
98
99
  end
99
100
 
100
101
  def pop
102
+ warn_if_dirty!
101
103
  ret = deserialize_value(dbclient.spop(dbkey))
102
104
  update_expiration
103
105
  ret
104
106
  end
105
107
 
106
108
  def move(dstkey, val)
109
+ warn_if_dirty!
107
110
  ret = dbclient.smove dbkey, dstkey, serialize_value(val)
108
111
  update_expiration
109
112
  ret
@@ -24,6 +24,8 @@ module Familia
24
24
 
25
25
  handle_method_conflict(klass, :"#{method_name}=") do
26
26
  klass.define_method :"#{method_name}=" do |value|
27
+ old_value = instance_variable_get(:"@#{field_name}")
28
+
27
29
  if value.nil?
28
30
  instance_variable_set(:"@#{field_name}", nil)
29
31
  elsif value.is_a?(::String) && value.empty?
@@ -44,6 +46,9 @@ module Familia
44
46
  concealed = ConcealedString.new(encrypted, self, field_type)
45
47
  instance_variable_set(:"@#{field_name}", concealed)
46
48
  end
49
+
50
+ # Track the change for dirty-tracking (only for Horreum instances)
51
+ mark_dirty!(field_name, old_value) if respond_to?(:mark_dirty!)
47
52
  end
48
53
  end
49
54
  end
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require 'concurrent/map'
6
+
5
7
  module Familia
6
8
  class Horreum
7
9
  # DirtyTracking - Tracks in-memory field changes since last save/refresh.
@@ -15,6 +17,13 @@ module Familia
15
17
  # Fields are marked dirty automatically by the setter defined in FieldType.
16
18
  # Dirty state is cleared after save, commit_fields, and refresh operations.
17
19
  #
20
+ # Uses Concurrent::Map for thread-safe access to the dirty fields tracker
21
+ # without requiring explicit mutex locks. The map is eagerly initialized
22
+ # in Horreum#initialize and the allocate-based load paths so that no
23
+ # lazy ||= race exists under normal usage. The ||= fallbacks in each
24
+ # method are a safety net for subclasses that override initialize
25
+ # without calling super (a documented anti-pattern).
26
+ #
18
27
  # @example
19
28
  # user = User.new(name: "Alice")
20
29
  # user.dirty? # => false (just initialized)
@@ -37,15 +46,10 @@ module Familia
37
46
  # @return [void]
38
47
  #
39
48
  def mark_dirty!(field_name, old_value)
40
- @dirty_fields ||= {}
41
- field_sym = field_name.to_sym
42
-
43
- # Only record the original old value on the first mutation.
44
- # Subsequent changes keep the original baseline so changed_fields
45
- # shows [original, current] rather than [previous, current].
46
- unless @dirty_fields.key?(field_sym)
47
- @dirty_fields[field_sym] = old_value
48
- end
49
+ # Safety net for subclasses that override initialize without calling super
50
+ @dirty_fields ||= Concurrent::Map.new
51
+ # Atomic: only stores old_value if field_sym is not already tracked.
52
+ @dirty_fields.put_if_absent(field_name.to_sym, old_value)
49
53
  end
50
54
 
51
55
  # Whether any fields (or a specific field) have unsaved changes.
@@ -54,8 +58,7 @@ module Familia
54
58
  # @return [Boolean]
55
59
  #
56
60
  def dirty?(field = nil)
57
- @dirty_fields ||= {}
58
-
61
+ @dirty_fields ||= Concurrent::Map.new
59
62
  if field
60
63
  @dirty_fields.key?(field.to_sym)
61
64
  else
@@ -68,7 +71,7 @@ module Familia
68
71
  # @return [Array<Symbol>] field names with unsaved changes
69
72
  #
70
73
  def dirty_fields
71
- @dirty_fields ||= {}
74
+ @dirty_fields ||= Concurrent::Map.new
72
75
  @dirty_fields.keys
73
76
  end
74
77
 
@@ -80,11 +83,13 @@ module Familia
80
83
  # @return [Hash{Symbol => Array(Object, Object)}]
81
84
  #
82
85
  def changed_fields
83
- @dirty_fields ||= {}
84
- @dirty_fields.each_with_object({}) do |(field_name, old_value), result|
86
+ @dirty_fields ||= Concurrent::Map.new
87
+ result = {}
88
+ @dirty_fields.each_pair do |field_name, old_value|
85
89
  current_value = instance_variable_get(:"@#{field_name}")
86
90
  result[field_name] = [old_value, current_value]
87
91
  end
92
+ result
88
93
  end
89
94
 
90
95
  # Clears dirty tracking state for all or specific fields.
@@ -98,8 +103,9 @@ module Familia
98
103
  # @return [void]
99
104
  #
100
105
  def clear_dirty!(*field_names)
106
+ @dirty_fields ||= Concurrent::Map.new
101
107
  if field_names.empty?
102
- @dirty_fields = {}
108
+ @dirty_fields.clear
103
109
  else
104
110
  field_names.each { |f| @dirty_fields.delete(f.to_sym) }
105
111
  end
@@ -748,8 +748,13 @@ module Familia
748
748
  # @api private
749
749
  def instantiate_from_hash(obj_hash)
750
750
  instance = allocate
751
+ instance.instance_variable_set(:@dirty_fields, Concurrent::Map.new)
751
752
  instance.send(:initialize_relatives)
752
753
  instance.send(:initialize_with_keyword_args_deserialize_value, **obj_hash)
754
+ # Object was just loaded from Redis, so it matches DB state exactly.
755
+ # Clear dirty flags set during field assignment above, mirroring what
756
+ # initialize (horreum.rb:246) and refresh! (persistence.rb:608) do.
757
+ instance.send(:clear_dirty!)
753
758
  instance
754
759
  end
755
760
 
@@ -794,6 +799,7 @@ module Familia
794
799
 
795
800
  # Use a temporary instance for deserialization (needs serialize_value/deserialize_value)
796
801
  temp = allocate
802
+ temp.instance_variable_set(:@dirty_fields, Concurrent::Map.new)
797
803
  temp.send(:initialize_relatives)
798
804
 
799
805
  raw_hash.each_with_object({}) do |(field, raw_val), result|
@@ -343,15 +343,14 @@ module Familia
343
343
  update_expiration = kwargs.delete(:update_expiration) { true }
344
344
  fields = kwargs
345
345
 
346
+ guard_allowed_fields!(fields.keys)
346
347
  Familia.trace :BATCH_UPDATE, nil, fields.keys if Familia.debug?
347
348
 
348
349
  result = transaction do |_conn|
349
- # 1. Update all fields atomically
350
+ # 1. Update all fields atomically (Redis only, no in-memory mutation)
350
351
  fields.each do |field, value|
351
352
  prepared_value = serialize_value(value)
352
353
  hset field, prepared_value
353
- # Update instance variable to keep object in sync
354
- send("#{field}=", value) if respond_to?("#{field}=")
355
354
  end
356
355
 
357
356
  # 2. Update expiration in same transaction
@@ -362,7 +361,14 @@ module Familia
362
361
  touch_instances!
363
362
  end
364
363
 
365
- clear_dirty!(*fields.keys) unless result.nil?
364
+ # Update in-memory state only after transaction succeeds,
365
+ # so a failed transaction never leaves the object diverged.
366
+ if result.is_a?(MultiResult) && result.successful?
367
+ fields.each do |field, value|
368
+ send("#{field}=", value) if respond_to?("#{field}=")
369
+ end
370
+ clear_dirty!(*fields.keys)
371
+ end
366
372
 
367
373
  result
368
374
  end
@@ -394,13 +400,13 @@ module Familia
394
400
  fields = kwargs
395
401
 
396
402
  raise ArgumentError, 'No fields specified' if fields.empty?
403
+ guard_allowed_fields!(fields.keys)
397
404
 
398
405
  Familia.trace :BATCH_FAST_WRITE, nil, fields.keys if Familia.debug?
399
406
 
400
- # Build serialized hash and update instance variables
407
+ # Serialize values before the transaction (read-only on instance)
401
408
  serialized = {}
402
409
  fields.each do |field, value|
403
- send(:"#{field}=", value) if respond_to?(:"#{field}=")
404
410
  serialized[field] = serialize_value(value)
405
411
  end
406
412
 
@@ -412,7 +418,14 @@ module Familia
412
418
  touch_instances!
413
419
  end
414
420
 
415
- clear_dirty!(*fields.keys) unless result.nil?
421
+ # Update in-memory state only after transaction succeeds,
422
+ # so a failed transaction never leaves the object diverged.
423
+ if result.is_a?(MultiResult) && result.successful?
424
+ fields.each do |field, value|
425
+ send(:"#{field}=", value) if respond_to?(:"#{field}=")
426
+ end
427
+ clear_dirty!(*fields.keys)
428
+ end
416
429
 
417
430
  self
418
431
  end
@@ -477,8 +490,8 @@ module Familia
477
490
  # # => #<User:0x007f8a1c8b0a28 @name="John", @email="john@example.com", @age=30>
478
491
  #
479
492
  def apply_fields(**fields)
493
+ guard_allowed_fields!(fields.keys)
480
494
  fields.each do |field, value|
481
- # Apply the field value if the setter method exists
482
495
  send("#{field}=", value) if respond_to?("#{field}=")
483
496
  end
484
497
  self
@@ -695,6 +708,27 @@ module Familia
695
708
 
696
709
  private
697
710
 
711
+ # Validates that all field names are declared Familia fields.
712
+ #
713
+ # Prevents mass-assignment of arbitrary setter methods (e.g. role=,
714
+ # admin=) that are not declared via the `field` or `transient` DSL.
715
+ # This is a defense-in-depth measure for downstream callers that may
716
+ # inadvertently pass unsanitized input to batch methods.
717
+ #
718
+ # @param names [Array<Symbol, String>] field names to validate
719
+ # @raise [ArgumentError] if any name is not a declared field
720
+ # @return [void]
721
+ #
722
+ def guard_allowed_fields!(names)
723
+ allowed = self.class.field_method_map.keys
724
+ unknown = names.map(&:to_sym) - allowed
725
+ return if unknown.empty?
726
+
727
+ raise ArgumentError,
728
+ "Undeclared fields for #{self.class}: #{unknown.join(', ')}. " \
729
+ "Only fields defined with `field` or `transient` are mass-assignable."
730
+ end
731
+
698
732
  # Reset all transient fields to nil
699
733
  #
700
734
  # This method ensures that transient fields return to their uninitialized
@@ -189,6 +189,7 @@ module Familia
189
189
  # `Session.new({sessid: "abc123", custid: "user456"})` # legacy hash (robust)
190
190
  #
191
191
  def initialize(*args, **kwargs)
192
+ @dirty_fields = Concurrent::Map.new
192
193
  start_time = Familia.now_in_μs if Familia.debug?
193
194
  Familia.trace :INITIALIZE, nil, "Initializing #{self.class}" if Familia.debug?
194
195
  initialize_relatives
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.3.0'
7
+ VERSION = '2.3.1'
8
8
  end
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative '../support/helpers/test_helpers'
6
+ require 'base64'
6
7
 
7
8
  class DirtyTrackUser < Familia::Horreum
8
9
  identifier_field :email
@@ -12,6 +13,19 @@ class DirtyTrackUser < Familia::Horreum
12
13
  field :active
13
14
  end
14
15
 
16
+ # Encrypted field model for dirty tracking tests
17
+ # Encryption keys must be configured before defining the class
18
+ Familia.config.encryption_keys = { v1: Base64.strict_encode64('a' * 32) }
19
+ Familia.config.current_key_version = :v1
20
+
21
+ class DirtyTrackSecureUser < Familia::Horreum
22
+ feature :encrypted_fields
23
+ identifier_field :user_id
24
+ field :user_id
25
+ field :display_name
26
+ encrypted_field :secret_token
27
+ end
28
+
15
29
  @user = DirtyTrackUser.new(email: 'alice@example.com', name: 'Alice', age: 30, active: true)
16
30
 
17
31
  ## Freshly constructed object is not dirty
@@ -181,19 +195,13 @@ end
181
195
  @wp.dirty?
182
196
  #=> false
183
197
 
184
- # Known bug: clear_dirty! blanket-resets all dirty state
198
+ # Selective clear_dirty! for partial write paths
185
199
  #
186
- # Every write path calls clear_dirty! after persisting, but partial write
187
- # paths (fast writers, save_fields, batch_update) only persist a subset of
188
- # fields. The blanket reset incorrectly clears dirty state for fields that
189
- # were NOT persisted, causing the object to report as clean when it still
190
- # has unsaved changes.
191
- #
192
- # These tests document the correct behavior. They are expected to FAIL
193
- # with the current implementation until clear_dirty! is fixed to only
194
- # clear the fields that were actually written.
200
+ # Partial write paths (fast writers, save_fields, batch_update) only persist
201
+ # a subset of fields. clear_dirty! selectively clears only the fields that
202
+ # were actually written, preserving dirty state for unwritten fields.
195
203
 
196
- ## Fast writer clears unrelated dirty fields (BUG: should preserve them)
204
+ ## Fast writer preserves dirty state for unwritten fields
197
205
  # When fields A and B are both dirty and only A is fast-written,
198
206
  # field B should still be marked dirty because it was not persisted.
199
207
  @bug1 = DirtyTrackUser.new(email: 'bug1@example.com', name: 'Original', age: 20)
@@ -217,7 +225,7 @@ end
217
225
  @bug1.dirty_fields
218
226
  #=> [:age]
219
227
 
220
- ## save_fields clears unrelated dirty fields (BUG: should preserve them)
228
+ ## save_fields preserves dirty state for unwritten fields
221
229
  # When fields A and B are both dirty and only A is saved via save_fields,
222
230
  # field B should still be marked dirty because it was not persisted.
223
231
  @bug2 = DirtyTrackUser.new(email: 'bug2@example.com', name: 'Original', age: 20)
@@ -497,8 +505,91 @@ end
497
505
  @clean.dirty?
498
506
  #=> false
499
507
 
508
+ # Issue #225: Objects loaded from Redis should not be dirty
509
+ # instantiate_from_hash (used by load, find_by_dbkey) was missing
510
+ # clear_dirty!, so loaded objects appeared dirty even though they matched DB state.
511
+
512
+ ## Object loaded via load should not be dirty
513
+ @loaded_user = DirtyTrackUser.new(email: 'load-clean@example.com', name: 'LoadClean', age: 25, active: true)
514
+ @loaded_user.save
515
+ @reloaded = DirtyTrackUser.load('load-clean@example.com')
516
+ @reloaded.dirty?
517
+ #=> false
518
+
519
+ ## Loaded object should have empty dirty_fields
520
+ @reloaded.dirty_fields
521
+ #=> []
522
+
523
+ ## Loaded object should have empty changed_fields
524
+ @reloaded.changed_fields
525
+ #=> {}
526
+
527
+ ## Object loaded via find_by_dbkey should not be dirty
528
+ @found = DirtyTrackUser.find_by_dbkey(DirtyTrackUser.dbkey('load-clean@example.com'))
529
+ @found.dirty?
530
+ #=> false
531
+
532
+ ## Modifying a loaded object marks it dirty as expected
533
+ @reloaded.name = 'Modified'
534
+ @reloaded.dirty?
535
+ #=> true
536
+
537
+ ## Loaded object dirty_fields reflects the modification
538
+ @reloaded.dirty_fields
539
+ #=> [:name]
540
+
541
+ # Encrypted field dirty tracking tests
542
+ #
543
+ # Encrypted field setters (EncryptedFieldType#define_setter) use
544
+ # instance_variable_set directly without calling mark_dirty!. This means
545
+ # setting an encrypted field does not mark the object as dirty, even though
546
+ # encrypted fields ARE persisted to Redis just like regular fields.
547
+ #
548
+ # These tests document the correct behavior and should FAIL until
549
+ # mark_dirty! is added to the encrypted field setter.
550
+
551
+ ## Setting an encrypted field should mark object as dirty
552
+ @enc1 = DirtyTrackSecureUser.new(user_id: 'enc-dirty-1', display_name: 'EncUser1')
553
+ @enc1.save
554
+ @enc1 = DirtyTrackSecureUser.load('enc-dirty-1')
555
+ @enc1.secret_token = 'my-secret-abc'
556
+ @enc1.dirty?
557
+ #=> true
558
+
559
+ ## Encrypted field should appear in dirty_fields
560
+ @enc1.dirty_fields
561
+ #=> [:secret_token]
562
+
563
+ ## Encrypted field should appear in changed_fields
564
+ @enc1.changed_fields.key?(:secret_token)
565
+ #=> true
566
+
567
+ ## Save should clear encrypted field dirty state
568
+ @enc2 = DirtyTrackSecureUser.new(user_id: 'enc-dirty-2', display_name: 'EncUser2')
569
+ @enc2.save
570
+ @enc2 = DirtyTrackSecureUser.load('enc-dirty-2')
571
+ @enc2.secret_token = 'another-secret'
572
+ @enc2.save
573
+ @enc2.dirty?
574
+ #=> false
575
+
576
+ ## Mixed dirty tracking: both regular and encrypted fields appear in dirty_fields
577
+ @enc3 = DirtyTrackSecureUser.new(user_id: 'enc-dirty-3', display_name: 'EncUser3')
578
+ @enc3.save
579
+ @enc3 = DirtyTrackSecureUser.load('enc-dirty-3')
580
+ @enc3.display_name = 'UpdatedName'
581
+ @enc3.secret_token = 'secret-xyz'
582
+ @enc3.dirty_fields.sort
583
+ #=> [:display_name, :secret_token]
584
+
500
585
  ## Teardown
501
586
  DirtyTrackUser.instances.members.each do |id|
502
587
  obj = DirtyTrackUser.new(id)
503
588
  obj.destroy! rescue nil
504
589
  end
590
+ DirtyTrackSecureUser.instances.members.each do |id|
591
+ obj = DirtyTrackSecureUser.new(id)
592
+ obj.destroy! rescue nil
593
+ end
594
+ Familia.config.encryption_keys = nil
595
+ Familia.config.current_key_version = nil
@@ -157,7 +157,10 @@ instances = 100.times.map { |i| PerfDomain.new(domain_id: "mem_test_#{i}") }
157
157
  after_instances = ObjectSpace.count_objects[:T_OBJECT]
158
158
 
159
159
  # Should not have created excessive objects (relationship metadata is shared)
160
- (after_instances - before_instances) < 250 # Allow for reasonable, 2.5x overhead
160
+ # Threshold accommodates per-instance allocations (Concurrent::Map for dirty
161
+ # tracking, DataType relatives, etc.) which vary across Ruby versions.
162
+ # Ruby 3.5+ allocates more T_OBJECTs internally per instance.
163
+ (after_instances - before_instances) < 800
161
164
  #=> true
162
165
 
163
166
  ## Class-level relationship metadata should be constant size
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: familia
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -330,8 +330,6 @@ files:
330
330
  - lib/middleware/database_command_counter.rb
331
331
  - lib/middleware/database_logger.rb
332
332
  - lib/multi_result.rb
333
- - pr_agent.toml
334
- - pr_compliance_checklist.yaml
335
333
  - try/audit/audit_instances_try.rb
336
334
  - try/audit/audit_report_try.rb
337
335
  - try/audit/audit_unique_indexes_try.rb
data/pr_agent.toml DELETED
@@ -1,36 +0,0 @@
1
- # Qodo Merge Configuration
2
- # Documentation: https://qodo-merge-docs.qodo.ai/usage-guide/configuration_options/
3
-
4
- [config]
5
- # Ensure consistent review language across all PRs
6
- response_language = "en"
7
-
8
- [rag_arguments]
9
- # Enable RAG context enrichment for codebase duplication compliance checks
10
- enable_rag = true
11
- # Include related repositories for comprehensive context
12
- rag_repo_list = ['onetimesecret/onetimesecret', 'delano/tryouts']
13
-
14
- [compliance]
15
- # Reference custom compliance checklist for project-specific rules
16
- custom_compliance_path = "pr_compliance_checklist.yaml"
17
-
18
- [pr_reviewer]
19
- # Disable automatic label additions (triggers Claude review workflow noise)
20
- enable_review_labels_security = false
21
- enable_review_labels_effort = false
22
-
23
- [ignore]
24
- # Reduce noise by excluding generated files and build artifacts
25
- glob = [
26
- "*.lock", # Lock files (Gemfile.lock, etc.)
27
- "*.gem", # Built gem files
28
- "vendor/**", # Vendored dependencies
29
- "tmp/**", # Temporary files
30
- "log/**", # Log files
31
- "data/**", # Data directories
32
- "public/**", # Public assets
33
- ".yardoc/**", # YARD documentation cache
34
- "dump.rdb", # Redis database dumps
35
- "appendonlydir/**", # Redis append-only file directory
36
- ]
@@ -1,45 +0,0 @@
1
- # Custom Compliance Checklist for Familia
2
- # Documentation: https://qodo-merge-docs.qodo.ai/tools/compliance/
3
-
4
- pr_compliances:
5
- - title: "ErrorHandling"
6
- compliance_label: true
7
- objective: "All external API calls and database operations must have proper error handling"
8
- success_criteria: "Try-catch blocks around external calls with appropriate logging or error handling mechanisms"
9
- failure_criteria: "External API calls, database operations, or network requests without error handling"
10
-
11
- - title: "TestCoverage"
12
- compliance_label: true
13
- objective: "New features must include corresponding tests using the Tryouts framework"
14
- success_criteria: "Test files present in try/ directory for new functionality following *_try.rb or *.try.rb naming convention"
15
- failure_criteria: "New code without test coverage or tests not following Tryouts framework conventions"
16
-
17
- - title: "ChangelogFragment"
18
- compliance_label: true
19
- objective: "User-facing changes must include a changelog"
20
- success_criteria: "New fragment file in changelog.d/ directory following the naming convention and RST format, or updates to CHANGELOG.rst in root directory, or explicit justification for omission"
21
- failure_criteria: "User-facing changes without changelog fragment, CHANGELOG.rst updates, or documentation updates"
22
-
23
- - title: "DocumentationUpdates"
24
- compliance_label: true
25
- objective: "API changes must be reflected in documentation"
26
- success_criteria: "YARD documentation comments for new public methods, or updates to docs/ for significant changes"
27
- failure_criteria: "New public APIs or significant behavior changes without documentation updates"
28
-
29
- - title: "BackwardCompatibility"
30
- compliance_label: true
31
- objective: "Changes must maintain backward compatibility or document breaking changes"
32
- success_criteria: "No breaking changes to public APIs, or breaking changes clearly documented in migration guides"
33
- failure_criteria: "Breaking changes without migration documentation or deprecation warnings"
34
-
35
- - title: "ThreadSafety"
36
- compliance_label: true
37
- objective: "Code handling shared state must be thread-safe"
38
- success_criteria: "Proper synchronization for shared mutable state, or clear documentation of thread-safety assumptions"
39
- failure_criteria: "Shared mutable state accessed without synchronization in concurrent contexts"
40
-
41
- - title: "DatabaseKeyNaming"
42
- compliance_label: true
43
- objective: "Database key generation must follow Familia conventions"
44
- success_criteria: "Keys use delim separator, avoid reserved keywords (ttl, db, valkey, redis), and handle empty identifiers"
45
- failure_criteria: "Keys using reserved keywords, empty identifiers, or non-standard separators"