familia 2.1.1 → 2.3.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +143 -0
  3. data/CLAUDE.md +84 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +1 -1
  6. data/docs/guides/feature-encrypted-fields.md +4 -4
  7. data/docs/guides/feature-object-identifiers.md +4 -0
  8. data/docs/guides/feature-relationships-indexing.md +5 -1
  9. data/docs/guides/feature-relationships-participation.md +1 -1
  10. data/docs/guides/feature-relationships.md +5 -1
  11. data/docs/guides/feature-system.md +1 -1
  12. data/docs/guides/field-system.md +40 -3
  13. data/docs/guides/optimized-loading.md +2 -2
  14. data/examples/datatype_standalone.rb +1 -1
  15. data/examples/encrypted_fields.rb +4 -4
  16. data/examples/json_usage_patterns.rb +2 -2
  17. data/lib/familia/connection/behavior.rb +1 -1
  18. data/lib/familia/connection/transaction_core.rb +1 -1
  19. data/lib/familia/data_type/serialization.rb +22 -5
  20. data/lib/familia/data_type/types/counter.rb +14 -5
  21. data/lib/familia/data_type/types/hashkey.rb +11 -2
  22. data/lib/familia/data_type/types/json_stringkey.rb +1 -1
  23. data/lib/familia/data_type/types/listkey.rb +17 -3
  24. data/lib/familia/data_type/types/sorted_set.rb +21 -8
  25. data/lib/familia/data_type/types/stringkey.rb +4 -0
  26. data/lib/familia/data_type/types/unsorted_set.rb +13 -3
  27. data/lib/familia/data_type.rb +123 -1
  28. data/lib/familia/features/expiration.rb +104 -13
  29. data/lib/familia/features/relationships/participation.rb +2 -2
  30. data/lib/familia/field_type.rb +14 -1
  31. data/lib/familia/horreum/connection.rb +3 -3
  32. data/lib/familia/horreum/definition.rb +7 -0
  33. data/lib/familia/horreum/dirty_tracking.rb +109 -0
  34. data/lib/familia/horreum/management/audit.rb +395 -0
  35. data/lib/familia/horreum/management/audit_report.rb +104 -0
  36. data/lib/familia/horreum/management/repair.rb +376 -0
  37. data/lib/familia/horreum/management.rb +147 -22
  38. data/lib/familia/horreum/persistence.rb +219 -15
  39. data/lib/familia/horreum/serialization.rb +34 -0
  40. data/lib/familia/horreum.rb +7 -1
  41. data/lib/familia/logging.rb +1 -1
  42. data/lib/familia/settings.rb +21 -1
  43. data/lib/familia/thread_safety/monitor.rb +4 -4
  44. data/lib/familia/version.rb +1 -1
  45. data/lib/middleware/database_logger.rb +4 -4
  46. data/try/audit/audit_instances_try.rb +119 -0
  47. data/try/audit/audit_report_try.rb +182 -0
  48. data/try/audit/audit_unique_indexes_try.rb +92 -0
  49. data/try/audit/compound_identifier_try.rb +119 -0
  50. data/try/audit/health_check_try.rb +101 -0
  51. data/try/audit/m2_rebuild_batch_try.rb +110 -0
  52. data/try/audit/m3_multi_index_stub_try.rb +175 -0
  53. data/try/audit/m5_repair_batched_try.rb +144 -0
  54. data/try/audit/participation_audit_try.rb +121 -0
  55. data/try/audit/participation_instance_audit_try.rb +206 -0
  56. data/try/audit/participation_list_audit_try.rb +187 -0
  57. data/try/audit/participation_set_audit_try.rb +101 -0
  58. data/try/audit/rebuild_instances_try.rb +117 -0
  59. data/try/audit/repair_all_integration_try.rb +150 -0
  60. data/try/audit/repair_all_try.rb +84 -0
  61. data/try/audit/repair_indexes_try.rb +77 -0
  62. data/try/audit/repair_instances_try.rb +94 -0
  63. data/try/audit/repair_participations_try.rb +182 -0
  64. data/try/audit/repair_score_correctness_try.rb +139 -0
  65. data/try/audit/scan_keys_try.rb +66 -0
  66. data/try/edge_cases/find_by_dbkey_race_condition_try.rb +66 -76
  67. data/try/features/atomic_counter_try.rb +134 -0
  68. data/try/features/atomicity_try.rb +138 -0
  69. data/try/features/count_any_edge_cases_try.rb +4 -4
  70. data/try/features/dirty_tracking_try.rb +504 -0
  71. data/try/features/expiration_cascade_try.rb +136 -0
  72. data/try/features/field_groups_try.rb +2 -2
  73. data/try/features/ghost_objects_try.rb +124 -0
  74. data/try/features/instance_registry_try.rb +112 -0
  75. data/try/features/load_missing_keys_try.rb +112 -0
  76. data/try/features/registry_methods_try.rb +125 -0
  77. data/try/features/relationships/participation_commands_verification_spec.rb +6 -7
  78. data/try/features/relationships/participation_commands_verification_try.rb +3 -3
  79. data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
  80. data/try/features/relationships/participation_reverse_index_try.rb +2 -2
  81. data/try/features/relationships/participation_target_class_resolution_try.rb +2 -2
  82. data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
  83. data/try/features/save_with_collections_try.rb +75 -0
  84. data/try/features/serialization_debug_try.rb +159 -0
  85. data/try/features/ttl_report_try.rb +183 -0
  86. data/try/integration/connection/transaction_fallback_integration_try.rb +1 -1
  87. data/try/integration/connection/transaction_mode_permissive_try.rb +1 -1
  88. data/try/integration/models/customer_try.rb +4 -4
  89. data/try/integration/relationships_persistence_round_trip_try.rb +1 -1
  90. data/try/integration/transaction_safety_core_try.rb +1 -1
  91. data/try/integration/transaction_safety_workflow_try.rb +11 -11
  92. data/try/migration/integration_try.rb +1 -1
  93. data/try/migration/model_try.rb +1 -1
  94. data/try/migration/pipeline_try.rb +2 -2
  95. data/try/migration/registry_try.rb +1 -1
  96. data/try/migration/runner_try.rb +1 -1
  97. data/try/migration/v1_to_v2_serialization_try.rb +2 -2
  98. data/try/performance/transaction_safety_benchmark_try.rb +20 -20
  99. data/try/support/benchmarks/deserialization_benchmark.rb +1 -1
  100. data/try/support/benchmarks/deserialization_correctness_test.rb +1 -1
  101. data/try/unit/data_types/counter_try.rb +2 -2
  102. data/try/unit/horreum/serialization_try.rb +4 -4
  103. data/try/unit/horreum/unique_index_guard_validation_try.rb +1 -1
  104. metadata +36 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 923e4c76b9ee95ca39160a66ec9534462b21b16e273fc27c6e67e707647da1a4
4
- data.tar.gz: c8dd58304412dd4d9ad57727a4044d8680c16f701869e3f8dd7d0e2a57ed221a
3
+ metadata.gz: 217726d80e8424a761dfa3aa0f565e9cdadd3da409654f5077226e1a85ae29ac
4
+ data.tar.gz: b9ce4a30717b220b78689332da5fe40fe790535029399fa2b0f9978dabd89ff5
5
5
  SHA512:
6
- metadata.gz: 0716f0b0e47a4758933547b884da5265ce186ce3eba55def7672e2540823b8f15671791ed7b2906f6862d863faa273436fb9e52434cec0e10a91a9bd7614ae9e
7
- data.tar.gz: 6e018ee75bb16fa2e1be7a7e812dbcd8d8ca9e8a3c82fe078ef19958e3736f01f8c5f34b925a9fc6a0fd3d7ca709604571a5915ee2b828f2a67539e61ac401a2
6
+ metadata.gz: ba13b9a6832ffce17a17548a398ca25f5588b6b7bc24e349151ddb3289173418947f833959fa83497e65d953ce781c51007c8a8516c42a5deab589e2e013100b
7
+ data.tar.gz: a2165e1aa07cdd4f7ca90f6c233dace66f9aba8647f652fb26f8ae484ee142506c218a57c57f1c9d86dc5a551958cd95a01be28de789b817ebd529280ee6c8f3
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,149 @@ 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.0:
11
+
12
+ 2.3.0 — 2026-02-26
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - ``touch_instances!`` and ``remove_from_instances!`` instance methods for
19
+ explicit instances timeline management. ``touch_instances!`` is idempotent
20
+ (ZADD updates the timestamp without duplicating).
21
+
22
+ - ``in_instances?(identifier)`` class method for O(log N) membership checks
23
+ against the ``instances`` sorted set without loading the object.
24
+
25
+ - Dirty tracking for scalar fields: ``dirty?``, ``dirty_fields``,
26
+ ``changed_fields``, ``clear_dirty!``. Setters automatically mark fields
27
+ dirty; state is cleared after ``save``, ``commit_fields``, and ``refresh!``.
28
+
29
+ - ``warn_if_dirty!`` guard on collection write methods (``add``, ``push``,
30
+ ``[]=``, ``value=``). Warns when the parent Horreum has unsaved scalar
31
+ changes. Enable ``Familia.strict_write_order = true`` to raise instead.
32
+
33
+ - ``ttl_report`` instance method on Expiration-enabled models. Returns a hash
34
+ showing TTL for the main key and all relation keys, useful for detecting
35
+ TTL drift.
36
+
37
+ - ``debug_fields`` instance method on Horreum. Returns a diagnostic hash
38
+ showing Ruby value, stored JSON, and type for each persistent field.
39
+
40
+ - Proactive consistency audit infrastructure for Horreum models. Every
41
+ subclass now has ``health_check``, ``audit_instances``,
42
+ ``audit_unique_indexes``, ``audit_multi_indexes``, and
43
+ ``audit_participations`` class methods to detect phantoms (timeline
44
+ entries without backing keys), missing entries (keys not in timeline),
45
+ stale index entries, and orphaned participation members. Issue #221.
46
+
47
+ - ``AuditReport`` data structure (``Data.define``) that wraps audit
48
+ results with ``healthy?``, ``to_h`` (summary counts), and ``to_s``
49
+ (human-readable) methods for quick inspection and programmatic use.
50
+
51
+ - Repair and rebuild operations: ``repair_instances!``,
52
+ ``rebuild_instances``, ``repair_indexes!``,
53
+ ``repair_participations!``, and ``repair_all!`` class methods.
54
+ ``rebuild_instances`` performs a full SCAN-based rebuild with atomic
55
+ swap via the existing ``RebuildStrategies`` infrastructure.
56
+
57
+ - ``scan_keys`` helper on ManagementMethods for production-safe
58
+ enumeration of keys matching a class pattern via SCAN.
59
+
60
+ - Participation audit reads actual collection contents (not the instances
61
+ timeline) and repairs use TYPE introspection to dispatch the correct
62
+ removal command per collection type.
63
+
64
+ Changed
65
+ -------
66
+
67
+ - ``find_by_dbkey`` and ``find_by_identifier`` are now read-only.
68
+ They no longer call ``cleanup_stale_instance_entry`` as a side effect
69
+ when a key is missing. Ghost cleanup is the explicit responsibility
70
+ of the audit/repair layer or direct caller invocation.
71
+ ``cleanup_stale_instance_entry`` is now a public class method.
72
+
73
+ - Fast writers (``field!``), ``batch_update``, ``batch_fast_write``,
74
+ and ``save_fields`` now clear dirty tracking state after a successful
75
+ database write. Note: this currently clears all dirty flags, even for
76
+ fields that were not part of the partial write. This known limitation
77
+ is documented in ``try/features/dirty_tracking_try.rb`` and will be
78
+ addressed in a future release.
79
+
80
+ Fixed
81
+ -----
82
+
83
+ - ``commit_fields``, ``batch_update``, ``save_fields``, and fast writers now
84
+ touch the ``instances`` sorted set via ``touch_instances!``.
85
+ Previously, only ``save`` updated the timeline, leaving objects created
86
+ through other write paths invisible to ``instances.to_a`` enumeration.
87
+
88
+ - Class-level ``destroy!`` now removes the identifier from the ``instances``
89
+ sorted set, preventing ghost entries after deletion.
90
+
91
+ Documentation
92
+ -------------
93
+
94
+ - Added serialization encoding guide to CLAUDE.md showing how each DataType
95
+ serializes values and what raw Redis output looks like per type.
96
+
97
+ AI Assistance
98
+ -------------
99
+
100
+ - Implementation, test authoring, and iterative debugging performed with
101
+ Claude Opus 4.6 assistance across dirty tracking, write-order guards,
102
+ TTL reporting, debug_fields, audit/repair infrastructure, and 211 test
103
+ cases across 14 audit files.
104
+
105
+ .. _changelog-2.2.0:
106
+
107
+ 2.2.0 — 2026-02-23
108
+ ==================
109
+
110
+ Added
111
+ -----
112
+
113
+ - Introduced ``reference: true`` option for DataType collection declarations.
114
+ Collections with this option store member identifiers raw instead of
115
+ JSON-encoding them, resolving the semantic mismatch between field storage
116
+ (type-preserving JSON) and collection member storage (identity references).
117
+
118
+ Fixed
119
+ -----
120
+
121
+ - Fixed serialization mismatch in ``instances`` sorted set where
122
+ ``persist_to_storage`` passed a string identifier (JSON-encoded as
123
+ ``"\"abc-123\""``), while direct calls passed Familia objects (stored raw as
124
+ ``abc-123``). Now passes ``self`` to ``instances.add`` and declares
125
+ ``reference: true`` on the collection, ensuring consistent storage.
126
+ (`#215 <https://github.com/delano/familia/issues/215>`_)
127
+
128
+ - Fixed ``UnsortedSet#pop`` returning raw Redis strings instead of deserialized
129
+ values.
130
+
131
+ - Fixed ``UnsortedSet#move`` passing raw values to Redis instead of serializing
132
+ them.
133
+
134
+ - Fixed ``SortedSet#increment`` truncating scores to integer (``.to_i``) instead
135
+ of preserving float precision (``.to_f``).
136
+
137
+ Documentation
138
+ -------------
139
+
140
+ - Added collection member serialization guide to ``docs/guides/field-system.md``
141
+ explaining the distinction between field serialization (JSON for type
142
+ preservation) and collection member serialization (raw identifiers for
143
+ reference collections).
144
+
145
+ AI Assistance
146
+ -------------
147
+
148
+ - Claude assisted with systematic audit of all ``.add()`` call sites and
149
+ collection declarations across the codebase, identifying the root cause of the
150
+ serialization mismatch and the three additional DataType method bugs discovered
151
+ during the audit.
152
+
10
153
  .. _changelog-2.1.1:
11
154
 
12
155
  2.1.1 — 2026-02-02
data/CLAUDE.md CHANGED
@@ -186,12 +186,96 @@ end
186
186
  - `active: true` (Boolean) stores as `"true"` in Redis and loads back as Boolean `true`
187
187
  - `metadata: {key: "value"}` (Hash) stores as JSON and loads back as Hash with proper types
188
188
 
189
+ **Serialization by Type** (what you see in `redis-cli HGETALL` or `GET`):
190
+
191
+ | Context | Serialize method | Ruby `"UK"` in Redis | Ruby `123` in Redis | Deserialize method |
192
+ |---|---|---|---|---|
193
+ | Horreum `field` | `serialize_value` (JSON) | `"\"UK\""` | `"123"` | `deserialize_value` (JSON parse) |
194
+ | `StringKey` | `.to_s` (raw) | `"UK"` | `"123"` | raw string (no parse) |
195
+ | `JsonStringKey` | JSON dump | `"\"UK\""` | `"123"` | JSON parse |
196
+ | `List/Set/SortedSet/HashKey` values | `serialize_value` (JSON) | `"\"UK\""` | `"123"` | `deserialize_value` (JSON parse) |
197
+
198
+ Key distinction: `StringKey` uses raw `.to_s` serialization (not JSON) to support Redis string operations like `INCR`, `DECR`, and `APPEND`. All other types use JSON encoding. When inspecting raw Redis output, a Horreum string field storing `"UK"` appears as `"\"UK\""` (double-quoted), while a `StringKey` storing `"UK"` appears as `"UK"` (no extra quotes).
199
+
200
+ Use `debug_fields` on a Horreum instance to see Ruby values vs stored JSON side-by-side:
201
+ ```ruby
202
+ user.debug_fields
203
+ # => {"country" => {ruby: "UK", stored: "\"UK\"", type: "String"},
204
+ # "age" => {ruby: 30, stored: "30", type: "Integer"}}
205
+ ```
206
+
189
207
  **Database Key Generation**: Automatic key generation using class name, identifier, and field/type names (aka dbkey). Pattern: `classname:identifier:fieldname`
190
208
 
191
209
  **Memory Efficiency**: Only non-nil values are stored in keystore database to optimize memory usage.
192
210
 
193
211
  **Thread Safety**: Data types are frozen after instantiation to ensure immutability.
194
212
 
213
+ ### Write Model: Deferred vs Immediate
214
+
215
+ Familia has a two-tier write model. Understanding when data hits Redis is critical for avoiding inconsistencies.
216
+
217
+ **Scalar fields** (defined with `field`) use deferred writes:
218
+ - Normal setters (`user.name = "Alice"`) only update the in-memory instance variable. Nothing is written to Redis until `save`, `commit_fields`, or `batch_update` is called.
219
+ - Fast writers (`user.name! "Alice"`) perform an immediate `HSET` on the object's hash key. Use these when you need a single field persisted without a full save cycle.
220
+
221
+ **Collection fields** (defined with `list`, `set`, `zset`, `hashkey`) use immediate writes:
222
+ - Every mutating method (`add`, `push`, `remove`, `clear`, `[]=`) executes the corresponding Redis command (SADD, RPUSH, ZREM, DEL, etc.) right away.
223
+ - Collection fields live on separate Redis keys from the object's hash, so they cannot participate in the same MULTI/EXEC transaction as scalar fields.
224
+
225
+ **Safe pattern -- scalars first, then collections:**
226
+ ```ruby
227
+ plan.name = "Premium"
228
+ plan.region = "US"
229
+ plan.save # HMSET for all scalar fields
230
+
231
+ # Collections mutated AFTER scalars are committed
232
+ plan.features.clear
233
+ plan.features.add("sso")
234
+ plan.features.add("priority_support")
235
+
236
+ # Or use the convenience wrapper:
237
+ plan.save_with_collections do
238
+ plan.features.clear
239
+ plan.features.add("sso")
240
+ end
241
+ ```
242
+
243
+ **Unsafe pattern -- collections before save:**
244
+ ```ruby
245
+ plan.name = "Premium"
246
+ plan.features.clear # Immediate: Redis DEL
247
+ plan.features.add("sso") # Immediate: Redis SADD
248
+ plan.save # If this raises, features are already mutated
249
+ ```
250
+
251
+ **Cross-database limitation**: MULTI/EXEC transactions only work within a single Redis database number. If scalar fields and a collection use different `logical_database` values, they cannot share a transaction. The `save_with_collections` pattern handles this by sequencing the operations rather than wrapping them in MULTI.
252
+
253
+ **Instances timeline**: The class-level `instances` sorted set is a timeline of last-modified times, not a registry. `persist_to_storage` (called by `save`) and `commit_fields`/`batch_update` all call `touch_instances!` to update the timestamp. Use `in_instances?(identifier)` for fast O(log N) checks without loading the object.
254
+
255
+ ### Instances Timeline Lifecycle
256
+
257
+ Every Horreum subclass has a class-level `instances` sorted set (a `class_sorted_set` with `reference: true`). This timeline maps identifiers to their last-write timestamp (ZADD score).
258
+
259
+ **Write paths that touch instances** (call `touch_instances!`):
260
+ - `save` / `save_if_not_exists!` (via `persist_to_storage`)
261
+ - `commit_fields`
262
+ - `batch_update`
263
+ - `save_fields`
264
+ - Fast writers (`field_name!`) via `FieldType` and `DefinitionMethods`
265
+
266
+ **Write paths that remove from instances**:
267
+ - Instance-level `destroy!` (via `remove_from_instances!`)
268
+ - Class-level `destroy!(identifier)` (direct `instances.remove`)
269
+ - `cleanup_stale_instance_entry` in `find_by_dbkey` (lazy, on-access pruning)
270
+
271
+ **Ghost objects**: When a hash key expires via TTL but the identifier still lingers in `instances`, enumerating `instances.to_a` returns identifiers for objects that no longer exist. These are cleaned lazily: `find_by_dbkey` detects the missing key and calls `cleanup_stale_instance_entry`. Code that enumerates without loading (e.g. `instances.members`) will still see ghosts.
272
+
273
+ **`in_instances?` vs `exists?`**:
274
+ - `in_instances?(id)` checks the `instances` sorted set -- fast O(log N), but may return true for expired keys (ghost entries) or false for objects created outside Familia
275
+ - `exists?(id)` checks the actual Redis hash key -- authoritative but requires a round-trip
276
+
277
+ **`load` / `find_by_id` bypasses instances**: These methods read directly from the hash key via HGETALL. They do not consult `instances`. A key can exist in Redis without being in instances (e.g. created by `commit_fields` in an older version), and vice versa.
278
+
195
279
  ## Thread Safety Considerations
196
280
 
197
281
  ### Current Thread Safety Status (as of 2025-10-21)
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.3.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
data/README.md CHANGED
@@ -92,7 +92,7 @@ end
92
92
 
93
93
  ```ruby
94
94
  # Create and save
95
- user = User.create(email: 'alice@example.com', name: 'Alice', created_at: Time.now.to_i)
95
+ user = User.create(email: 'alice@example.com', name: 'Alice', created_at: Familia.now)
96
96
 
97
97
  # Find by identifier
98
98
  user = User.load('alice@example.com')
@@ -241,7 +241,7 @@ doc = SecureDocument.new(
241
241
  document_id: 'doc123',
242
242
  owner_id: 'user456',
243
243
  content: 'Sensitive document content',
244
- created_at: Time.now.to_i
244
+ created_at: Familia.now.to_i
245
245
  )
246
246
 
247
247
  doc.save
@@ -551,9 +551,9 @@ class EncryptionMonitor
551
551
  total_time = 0
552
552
 
553
553
  Familia::Encryption.define_singleton_method(:encrypt) do |data, **opts|
554
- start_time = Time.now
554
+ start_time = Familia.now
555
555
  result = original_encrypt.call(data, **opts)
556
- total_time += (Time.now - start_time)
556
+ total_time += (Familia.now - start_time)
557
557
  call_count += 1
558
558
 
559
559
  if call_count % 100 == 0
@@ -813,7 +813,7 @@ class EncryptionHealthCheck
813
813
  end
814
814
 
815
815
  # Test encrypt/decrypt cycle
816
- test_data = "health_check_#{Time.now.to_i}"
816
+ test_data = "health_check_#{Familia.now.to_i}"
817
817
  encrypted = Familia::Encryption.encrypt(test_data)
818
818
  decrypted = Familia::Encryption.decrypt(encrypted)
819
819
  results[:sample_encrypt_decrypt] = (decrypted == test_data)
@@ -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
@@ -262,7 +262,7 @@ class Event < Familia::Horreum
262
262
  end
263
263
  end
264
264
 
265
- today = Time.now.strftime('%Y%m%d')
265
+ today = Familia.now.strftime('%Y%m%d')
266
266
  todays_events = user.find_all_by_daily_partition(today)
267
267
  ```
268
268
 
@@ -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 |
@@ -175,7 +175,7 @@ active_tasks = project.tasks.range_by_score(1, '+inf')
175
175
  participates_in User, :sessions, score: :expires_at
176
176
 
177
177
  # Query active sessions
178
- now = Time.now.to_i
178
+ now = Familia.now.to_i
179
179
  active = user.sessions.range_by_score(now, '+inf')
180
180
  ```
181
181
 
@@ -91,7 +91,7 @@ class Task < Familia::Horreum
91
91
 
92
92
  # Lambda-based scoring
93
93
  participates_in Sprint, :tasks, score: -> {
94
- priority * 100 + (Time.now - created_at) / 3600
94
+ priority * 100 + (Familia.now - created_at) / 3600
95
95
  }
96
96
  end
97
97
 
@@ -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])`
@@ -245,7 +245,7 @@ module Familia
245
245
  field: field,
246
246
  old_value: instance_variable_was(field),
247
247
  new_value: instance_variable_get("@#{field}"),
248
- timestamp: Time.now.to_f
248
+ timestamp: Familia.now.to_f
249
249
  }
250
250
 
251
251
  self.class.audit_log.append(change_record.to_json)
@@ -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
@@ -376,7 +413,7 @@ class AuditedFieldType < Familia::FieldType
376
413
 
377
414
  # Audit the change
378
415
  old_value = hget(field_name)
379
- timestamp = Time.now.to_i
416
+ timestamp = Familia.now.to_i
380
417
 
381
418
  # Log the change
382
419
  puts "AUDIT: #{field_name} changed from #{old_value} to #{val} at #{timestamp}"
@@ -640,8 +677,8 @@ describe TimestampFieldType do
640
677
  expect(instance.created_at).to be_a(Time)
641
678
  expect(instance.created_at.to_s).to include("2024-01-01 12:00:00")
642
679
 
643
- instance.created_at = Time.now
644
- expect(instance.created_at).to be_a(Time)
680
+ instance.created_at = Familia.now
681
+ expect(instance.created_at).to be_a(Familia)
645
682
  end
646
683
 
647
684
  it "serializes to integer" do
@@ -466,9 +466,9 @@ domains = Domain.load_multi(domain_ids).compact
466
466
  **Step 3**: Profile the change
467
467
  ```ruby
468
468
  # Add logging temporarily
469
- start = Time.now
469
+ start = Familia.now
470
470
  domains = Domain.load_multi(domain_ids).compact
471
- duration = Time.now - start
471
+ duration = Familia.now - start
472
472
  Rails.logger.info "Loaded #{domains.size} domains in #{duration}s"
473
473
  ```
474
474
 
@@ -232,7 +232,7 @@ class DemoApp
232
232
  session_data = {
233
233
  'user_id' => '12345',
234
234
  'username' => 'demo_user',
235
- 'login_time' => Time.now.to_i,
235
+ 'login_time' => Familia.now.to_i,
236
236
  'preferences' => { 'theme' => 'dark', 'lang' => 'en' },
237
237
  }
238
238
 
@@ -126,7 +126,7 @@ puts 'Example 3: Performance optimization with request caching'
126
126
  entries = []
127
127
 
128
128
  # Without caching - each field derives keys independently
129
- start_time = Time.now
129
+ start_time = Familia.now
130
130
  5.times do |i|
131
131
  private_key_pem = <<~PEM
132
132
  -----BEGIN PRIVATE KEY-----
@@ -145,10 +145,10 @@ start_time = Time.now
145
145
  entry.save
146
146
  entries << entry
147
147
  end
148
- no_cache_time = Time.now - start_time
148
+ no_cache_time = Familia.now - start_time
149
149
 
150
150
  # With caching - reuses derived keys within the block
151
- start_time = Time.now
151
+ start_time = Familia.now
152
152
  Familia::Encryption.with_request_cache do
153
153
  5.times do |i|
154
154
  private_key_pem = <<~PEM
@@ -169,7 +169,7 @@ Familia::Encryption.with_request_cache do
169
169
  entries << entry
170
170
  end
171
171
  end
172
- cached_time = Time.now - start_time
172
+ cached_time = Familia.now - start_time
173
173
 
174
174
  puts "Encryption without caching: #{(no_cache_time * 1000).round(2)}ms"
175
175
  puts "Encryption with caching: #{(cached_time * 1000).round(2)}ms"
@@ -58,7 +58,7 @@ mixed_data = {
58
58
  user: user.as_json,
59
59
  tags: user.tags.as_json,
60
60
  permissions: user.permissions.as_json,
61
- meta: { timestamp: Time.now.to_i }
61
+ meta: { timestamp: Familia.now.to_i }
62
62
  }
63
63
  puts " Manual preparation + JsonSerializer.dump:"
64
64
  puts Familia::JsonSerializer.dump(mixed_data)
@@ -77,7 +77,7 @@ using Familia::Refinements::DearJson
77
77
  mixed_hash = {
78
78
  user: user, # Familia object (will call as_json)
79
79
  tags: user.tags, # Familia DataType (will call as_json)
80
- meta: { timestamp: Time.now.to_i } # Plain hash (passes through)
80
+ meta: { timestamp: Familia.now.to_i } # Plain hash (passes through)
81
81
  }
82
82
 
83
83
  mixed_array = [
@@ -217,7 +217,7 @@ module Familia
217
217
  # obj.pipelined do |conn|
218
218
  # conn.hmset(obj.dbkey, obj.to_h)
219
219
  # conn.hincrby(obj.dbkey, 'count', 1)
220
- # conn.hset(obj.dbkey, 'updated_at', Time.now.to_i)
220
+ # conn.hset(obj.dbkey, 'updated_at', Familia.now.to_i)
221
221
  # end
222
222
  #
223
223
  # @note Connection Inheritance:
@@ -40,7 +40,7 @@ module Familia
40
40
  #
41
41
  # customer.transaction do
42
42
  # customer.increment(:login_count)
43
- # customer.hset(:last_login, Time.now.to_i)
43
+ # customer.hset(:last_login, Familia.now.to_i)
44
44
  # end
45
45
  #
46
46
  # @example Incorrect Pattern: Save Inside Transaction
@@ -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
@@ -15,11 +15,20 @@ module Familia
15
15
  end
16
16
 
17
17
  def increment_if_less_than(threshold, amount = 1)
18
- current = to_i
19
- return false if current >= threshold
20
-
21
- incrementby(amount)
22
- true
18
+ lua = <<~LUA
19
+ local current = tonumber(redis.call('GET', KEYS[1]) or '0')
20
+ if current < tonumber(ARGV[1]) then
21
+ return redis.call('INCRBY', KEYS[1], ARGV[2])
22
+ end
23
+ return nil
24
+ LUA
25
+ result = dbclient.eval(lua, keys: [dbkey], argv: [threshold, amount])
26
+ if result
27
+ update_expiration
28
+ result.to_i
29
+ else
30
+ false
31
+ end
23
32
  end
24
33
 
25
34
  def atomic_increment_and_get(amount = 1)