familia 2.1.1 → 2.2.0

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: 923e4c76b9ee95ca39160a66ec9534462b21b16e273fc27c6e67e707647da1a4
4
- data.tar.gz: c8dd58304412dd4d9ad57727a4044d8680c16f701869e3f8dd7d0e2a57ed221a
3
+ metadata.gz: b5608f472e264e8cbf6228e398d4a25517173e55ad0d7d10a2b74de31121b18f
4
+ data.tar.gz: 665996a35ca541c9884453c4c664d197b865bdba888e424cdd8a5a555df96a37
5
5
  SHA512:
6
- metadata.gz: 0716f0b0e47a4758933547b884da5265ce186ce3eba55def7672e2540823b8f15671791ed7b2906f6862d863faa273436fb9e52434cec0e10a91a9bd7614ae9e
7
- data.tar.gz: 6e018ee75bb16fa2e1be7a7e812dbcd8d8ca9e8a3c82fe078ef19958e3736f01f8c5f34b925a9fc6a0fd3d7ca709604571a5915ee2b828f2a67539e61ac401a2
6
+ metadata.gz: 22c1c274df0683053f768b418e7bc239fbc31fce4decf405af2e87c0b60f6f0c365c7dd88ba7a897746fffd785bdf15cfba71549ea8f32a0cdbf054eeb2467fc
7
+ data.tar.gz: b8bbe2ec2153c5de43b98bf9e03e80e94d494d056bbb0b65711f20b76fb77b99f5847be4b5420ccce5497ef18fb29dd38fd7f8329b67ce4928dff6d6b43546d4
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,54 @@ 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.2.0:
11
+
12
+ 2.2.0 — 2026-02-23
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Introduced ``reference: true`` option for DataType collection declarations.
19
+ Collections with this option store member identifiers raw instead of
20
+ JSON-encoding them, resolving the semantic mismatch between field storage
21
+ (type-preserving JSON) and collection member storage (identity references).
22
+
23
+ Fixed
24
+ -----
25
+
26
+ - Fixed serialization mismatch in ``instances`` sorted set where
27
+ ``persist_to_storage`` passed a string identifier (JSON-encoded as
28
+ ``"\"abc-123\""``), while direct calls passed Familia objects (stored raw as
29
+ ``abc-123``). Now passes ``self`` to ``instances.add`` and declares
30
+ ``reference: true`` on the collection, ensuring consistent storage.
31
+ (`#215 <https://github.com/delano/familia/issues/215>`_)
32
+
33
+ - Fixed ``UnsortedSet#pop`` returning raw Redis strings instead of deserialized
34
+ values.
35
+
36
+ - Fixed ``UnsortedSet#move`` passing raw values to Redis instead of serializing
37
+ them.
38
+
39
+ - Fixed ``SortedSet#increment`` truncating scores to integer (``.to_i``) instead
40
+ of preserving float precision (``.to_f``).
41
+
42
+ Documentation
43
+ -------------
44
+
45
+ - Added collection member serialization guide to ``docs/guides/field-system.md``
46
+ explaining the distinction between field serialization (JSON for type
47
+ preservation) and collection member serialization (raw identifiers for
48
+ reference collections).
49
+
50
+ AI Assistance
51
+ -------------
52
+
53
+ - Claude assisted with systematic audit of all ``.add()`` call sites and
54
+ collection declarations across the codebase, identifying the root cause of the
55
+ serialization mismatch and the three additional DataType method bugs discovered
56
+ during the audit.
57
+
10
58
  .. _changelog-2.1.1:
11
59
 
12
60
  2.1.1 — 2026-02-02
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.1.1)
4
+ familia (2.2.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -193,6 +193,10 @@ custom.objid_generator_used # => nil (unknown provenance)
193
193
  - Debugging and auditing benefit from generator tracking
194
194
  - Format validation can be performed based on expected generator
195
195
 
196
+ ## Identifiers in Collections
197
+
198
+ When object identifiers are stored in DataType collections (sorted sets, sets, lists), they are stored as **raw strings** — not JSON-encoded. This is critical for consistent membership checks and lookups. Collections that hold object references must be declared with `class:` and `reference: true` options so that `serialize_value` treats both Familia objects and plain identifier strings identically. See [Collection Member Serialization](field-system.md#collection-member-serialization) for the full explanation of why this distinction exists.
199
+
196
200
  ## Lookup Management
197
201
 
198
202
  ### Automatic Mapping
@@ -366,6 +366,10 @@ company.badge_index.to_h.each do |badge, emp_id|
366
366
  end
367
367
  ```
368
368
 
369
+ ## Index Storage Format
370
+
371
+ Index values (the object identifiers stored in hash keys and sets) are raw strings, not JSON-encoded. This is a deliberate design choice shared across all Familia collections that store object references — it ensures that lookups, membership checks, and key construction all operate on the same byte representation. See [Collection Member Serialization](field-system.md#collection-member-serialization) for the underlying serialization rules.
372
+
369
373
  ## Redis Key Patterns
370
374
 
371
375
  | Type | Pattern | Example |
@@ -167,6 +167,10 @@ team.members.to_a # Just IDs
167
167
  team.member_instances # Load objects
168
168
  ```
169
169
 
170
+ ## Serialization of Collection Members
171
+
172
+ Relationship collections (participation sorted sets, index hash keys, instance-scoped sets) store object identifiers as raw strings. When adding objects to these collections, `serialize_value` extracts the `.identifier` from Familia objects and stores it without JSON encoding. This ensures consistent membership checks regardless of whether code passes an object reference or a string identifier. See [Collection Member Serialization](field-system.md#collection-member-serialization) for the authoritative explanation of why reference collections use raw identifiers while value fields use JSON.
173
+
170
174
  ## Best Practices
171
175
 
172
176
  1. **Use bulk methods** for multiple additions: `add_domains([d1, d2, d3])`
@@ -146,6 +146,43 @@ user.preferences["theme"] = "dark"
146
146
  user.login_count.increment
147
147
  ```
148
148
 
149
+ ## Collection Member Serialization
150
+
151
+ DataType collections (lists, sets, sorted sets, hash keys) and hashkey fields use different serialization strategies based on what they store.
152
+
153
+ ### Fields: JSON Serialization for Type Preservation
154
+
155
+ Hashkey fields store arbitrary Ruby values — integers, booleans, hashes, nil. All values are JSON-encoded so types survive the Redis round-trip. An Integer `35` stores as `"35"` and loads back as Integer `35`, not String `"35"`.
156
+
157
+ ### Collections: Raw Identifiers for Object References
158
+
159
+ When a collection's members represent references to Familia objects, those members must be stored as **raw identifier strings** — not JSON-encoded. Identifiers are lookup keys: they're matched against, compared with, and used to construct Redis keys (e.g. `customer:abc-def-123:object`). JSON-encoding an identifier produces a different byte sequence (`"\"abc-def-123\""` vs `abc-def-123`), which causes silent duplicates and broken membership checks.
160
+
161
+ The `class:` and `reference: true` options on a collection declaration tell `serialize_value` that members are object references, not arbitrary values:
162
+
163
+ ```ruby
164
+ class Customer < Familia::Horreum
165
+ # Members are object references — stored as raw identifiers
166
+ class_sorted_set :instances, class: self, reference: true
167
+
168
+ # Members are arbitrary values — stored as JSON
169
+ list :activity_log
170
+ end
171
+ ```
172
+
173
+ With these options set, `serialize_value` normalizes both code paths:
174
+ - Passing a Familia object extracts `.identifier` and stores it raw
175
+ - Passing a String identifier for a Familia class stores it raw (same result)
176
+ - Passing any other value JSON-encodes it for type preservation
177
+
178
+ Without `class:` metadata, a collection has no way to distinguish "this string is an identifier" from "this string is an arbitrary value" — and the two paths silently diverge.
179
+
180
+ ### The `instances` Sorted Set
181
+
182
+ Every Horreum subclass automatically gets a `class_sorted_set :instances` — a class-level registry of persisted objects. Members are raw identifier strings; scores are timestamps of when each object was last saved. This is the index used to enumerate all known instances of a class, check persistence, or clean up stale entries.
183
+
184
+ Because `instances` stores object references, it is declared with `class:` and `reference: true` to ensure consistent serialization regardless of whether callers pass an object or a string identifier.
185
+
149
186
  ## Advanced Field Types
150
187
 
151
188
  ### Creating Custom Field Types
@@ -38,6 +38,15 @@ module Familia
38
38
  return prepared
39
39
  end
40
40
 
41
+ # Priority 1b: If this collection stores object references (reference: true)
42
+ # and the value is a String, treat it as a raw identifier. This prevents
43
+ # mismatches when callers pass identifier strings directly instead of
44
+ # Familia objects.
45
+ if val.is_a?(String) && opts[:reference]
46
+ Familia.debug " String identifier (reference): #{val}"
47
+ return val
48
+ end
49
+
41
50
  # Priority 2: Everything else gets JSON serialized for type preservation
42
51
  # This unifies behavior with Horreum fields (Issue #190)
43
52
  prepared = Familia::JsonSerializer.dump(val)
@@ -80,6 +89,11 @@ module Familia
80
89
  Familia.debug "deserialize_values: (#{@opts}) #{values}"
81
90
  return [] if values.empty?
82
91
 
92
+ # Reference collections store raw identifiers — return as-is
93
+ if @opts[:reference]
94
+ return values.flatten
95
+ end
96
+
83
97
  # If a class option is specified, use class-based deserialization
84
98
  if @opts[:class]
85
99
  unless @opts[:class].respond_to?(:from_json)
@@ -94,9 +108,9 @@ module Familia
94
108
 
95
109
  val
96
110
  rescue StandardError => e
97
- Familia.info obj
98
- Familia.info "Parse error for #{dbkey} (from_json): #{e.message}"
99
- Familia.info e.backtrace
111
+ Familia.debug "[deserialize] from_json error in #{dbkey}: #{e.message}"
112
+ Familia.debug " raw value: #{obj.inspect[0..80]}"
113
+ Familia.trace :DESERIALIZE_ERROR, dbkey, e.message if Familia.debug?
100
114
  nil
101
115
  end
102
116
 
@@ -110,7 +124,7 @@ module Familia
110
124
  begin
111
125
  Familia::JsonSerializer.parse(obj)
112
126
  rescue Familia::SerializerError
113
- # Fallback for legacy data stored without JSON encoding
127
+ Familia.debug "[deserialize] Raw fallback in #{dbkey}: #{obj.inspect[0..80]}"
114
128
  obj
115
129
  end
116
130
  end
@@ -138,6 +152,9 @@ module Familia
138
152
 
139
153
  return @opts[:default] if val.nil?
140
154
 
155
+ # Reference collections store raw identifiers — return as-is
156
+ return val if @opts[:reference]
157
+
141
158
  # If a class option is specified, use the existing class-based deserialization
142
159
  if @opts[:class]
143
160
  ret = deserialize_values val
@@ -149,7 +166,7 @@ module Familia
149
166
  begin
150
167
  Familia::JsonSerializer.parse(val)
151
168
  rescue Familia::SerializerError
152
- # Fallback for legacy data stored without JSON encoding
169
+ Familia.debug "[deserialize] Raw fallback in #{dbkey}: #{val.inspect[0..80]}"
153
170
  val
154
171
  end
155
172
  end
@@ -269,7 +269,7 @@ module Familia
269
269
  end
270
270
 
271
271
  def increment(val, by = 1)
272
- dbclient.zincrby(dbkey, by, serialize_value(val)).to_i
272
+ dbclient.zincrby(dbkey, by, serialize_value(val)).to_f
273
273
  end
274
274
  alias incr increment
275
275
  alias incrby increment
@@ -92,11 +92,11 @@ module Familia
92
92
  end
93
93
 
94
94
  def pop
95
- dbclient.spop dbkey
95
+ deserialize_value(dbclient.spop(dbkey))
96
96
  end
97
97
 
98
98
  def move(dstkey, val)
99
- dbclient.smove dbkey, dstkey, val
99
+ dbclient.smove dbkey, dstkey, serialize_value(val)
100
100
  end
101
101
 
102
102
  # Get one or more random members from the set
@@ -25,7 +25,7 @@ module Familia
25
25
  using Familia::Refinements::TimeLiterals
26
26
 
27
27
  @registered_types = {}
28
- @valid_options = %i[class parent default_expiration default logical_database dbkey dbclient suffix prefix].freeze
28
+ @valid_options = %i[class parent default_expiration default logical_database dbkey dbclient suffix prefix reference].freeze
29
29
  @logical_database = nil
30
30
 
31
31
  feature :expiration
@@ -643,7 +643,7 @@ module Familia
643
643
  auto_update_class_indexes
644
644
 
645
645
  # 4. Add to instances collection if available
646
- self.class.instances.add(identifier, Familia.now) if self.class.respond_to?(:instances)
646
+ self.class.instances.add(self, Familia.now) if self.class.respond_to?(:instances)
647
647
 
648
648
  hmset_result
649
649
  end
@@ -169,7 +169,7 @@ module Familia
169
169
  Familia.members << member
170
170
 
171
171
  # Set up automatic instance tracking using built-in class_sorted_set
172
- member.class_sorted_set :instances
172
+ member.class_sorted_set :instances, class: member, reference: true
173
173
 
174
174
  super
175
175
  end
@@ -4,5 +4,5 @@
4
4
 
5
5
  module Familia
6
6
  # Version information for the Familia
7
- VERSION = '2.1.1' unless defined?(Familia::VERSION)
7
+ VERSION = '2.2.0' unless defined?(Familia::VERSION)
8
8
  end
@@ -98,7 +98,7 @@ multi_result = @customer.destroy!
98
98
  cust = Customer.find_by_id('test@example.com')
99
99
  exists = Customer.exists?('test@example.com')
100
100
  [multi_result.results, cust.nil?, exists]
101
- #=> [[1, 0, 1, 1, 1, 1, 1, true], true, false]
101
+ #=> [[1, 0, 1, 1, 1, 1, 1, false], true, false]
102
102
 
103
103
  ## Customer.destroy! can be called on an already destroyed object
104
104
  @customer.destroy!
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.1.1
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum