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.
- checksums.yaml +4 -4
- data/CHANGELOG.rst +143 -0
- data/CLAUDE.md +84 -0
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/docs/guides/feature-encrypted-fields.md +4 -4
- data/docs/guides/feature-object-identifiers.md +4 -0
- data/docs/guides/feature-relationships-indexing.md +5 -1
- data/docs/guides/feature-relationships-participation.md +1 -1
- data/docs/guides/feature-relationships.md +5 -1
- data/docs/guides/feature-system.md +1 -1
- data/docs/guides/field-system.md +40 -3
- data/docs/guides/optimized-loading.md +2 -2
- data/examples/datatype_standalone.rb +1 -1
- data/examples/encrypted_fields.rb +4 -4
- data/examples/json_usage_patterns.rb +2 -2
- data/lib/familia/connection/behavior.rb +1 -1
- data/lib/familia/connection/transaction_core.rb +1 -1
- data/lib/familia/data_type/serialization.rb +22 -5
- data/lib/familia/data_type/types/counter.rb +14 -5
- data/lib/familia/data_type/types/hashkey.rb +11 -2
- data/lib/familia/data_type/types/json_stringkey.rb +1 -1
- data/lib/familia/data_type/types/listkey.rb +17 -3
- data/lib/familia/data_type/types/sorted_set.rb +21 -8
- data/lib/familia/data_type/types/stringkey.rb +4 -0
- data/lib/familia/data_type/types/unsorted_set.rb +13 -3
- data/lib/familia/data_type.rb +123 -1
- data/lib/familia/features/expiration.rb +104 -13
- data/lib/familia/features/relationships/participation.rb +2 -2
- data/lib/familia/field_type.rb +14 -1
- data/lib/familia/horreum/connection.rb +3 -3
- data/lib/familia/horreum/definition.rb +7 -0
- data/lib/familia/horreum/dirty_tracking.rb +109 -0
- data/lib/familia/horreum/management/audit.rb +395 -0
- data/lib/familia/horreum/management/audit_report.rb +104 -0
- data/lib/familia/horreum/management/repair.rb +376 -0
- data/lib/familia/horreum/management.rb +147 -22
- data/lib/familia/horreum/persistence.rb +219 -15
- data/lib/familia/horreum/serialization.rb +34 -0
- data/lib/familia/horreum.rb +7 -1
- data/lib/familia/logging.rb +1 -1
- data/lib/familia/settings.rb +21 -1
- data/lib/familia/thread_safety/monitor.rb +4 -4
- data/lib/familia/version.rb +1 -1
- data/lib/middleware/database_logger.rb +4 -4
- data/try/audit/audit_instances_try.rb +119 -0
- data/try/audit/audit_report_try.rb +182 -0
- data/try/audit/audit_unique_indexes_try.rb +92 -0
- data/try/audit/compound_identifier_try.rb +119 -0
- data/try/audit/health_check_try.rb +101 -0
- data/try/audit/m2_rebuild_batch_try.rb +110 -0
- data/try/audit/m3_multi_index_stub_try.rb +175 -0
- data/try/audit/m5_repair_batched_try.rb +144 -0
- data/try/audit/participation_audit_try.rb +121 -0
- data/try/audit/participation_instance_audit_try.rb +206 -0
- data/try/audit/participation_list_audit_try.rb +187 -0
- data/try/audit/participation_set_audit_try.rb +101 -0
- data/try/audit/rebuild_instances_try.rb +117 -0
- data/try/audit/repair_all_integration_try.rb +150 -0
- data/try/audit/repair_all_try.rb +84 -0
- data/try/audit/repair_indexes_try.rb +77 -0
- data/try/audit/repair_instances_try.rb +94 -0
- data/try/audit/repair_participations_try.rb +182 -0
- data/try/audit/repair_score_correctness_try.rb +139 -0
- data/try/audit/scan_keys_try.rb +66 -0
- data/try/edge_cases/find_by_dbkey_race_condition_try.rb +66 -76
- data/try/features/atomic_counter_try.rb +134 -0
- data/try/features/atomicity_try.rb +138 -0
- data/try/features/count_any_edge_cases_try.rb +4 -4
- data/try/features/dirty_tracking_try.rb +504 -0
- data/try/features/expiration_cascade_try.rb +136 -0
- data/try/features/field_groups_try.rb +2 -2
- data/try/features/ghost_objects_try.rb +124 -0
- data/try/features/instance_registry_try.rb +112 -0
- data/try/features/load_missing_keys_try.rb +112 -0
- data/try/features/registry_methods_try.rb +125 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +6 -7
- data/try/features/relationships/participation_commands_verification_try.rb +3 -3
- data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
- data/try/features/relationships/participation_reverse_index_try.rb +2 -2
- data/try/features/relationships/participation_target_class_resolution_try.rb +2 -2
- data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
- data/try/features/save_with_collections_try.rb +75 -0
- data/try/features/serialization_debug_try.rb +159 -0
- data/try/features/ttl_report_try.rb +183 -0
- data/try/integration/connection/transaction_fallback_integration_try.rb +1 -1
- data/try/integration/connection/transaction_mode_permissive_try.rb +1 -1
- data/try/integration/models/customer_try.rb +4 -4
- data/try/integration/relationships_persistence_round_trip_try.rb +1 -1
- data/try/integration/transaction_safety_core_try.rb +1 -1
- data/try/integration/transaction_safety_workflow_try.rb +11 -11
- data/try/migration/integration_try.rb +1 -1
- data/try/migration/model_try.rb +1 -1
- data/try/migration/pipeline_try.rb +2 -2
- data/try/migration/registry_try.rb +1 -1
- data/try/migration/runner_try.rb +1 -1
- data/try/migration/v1_to_v2_serialization_try.rb +2 -2
- data/try/performance/transaction_safety_benchmark_try.rb +20 -20
- data/try/support/benchmarks/deserialization_benchmark.rb +1 -1
- data/try/support/benchmarks/deserialization_correctness_test.rb +1 -1
- data/try/unit/data_types/counter_try.rb +2 -2
- data/try/unit/horreum/serialization_try.rb +4 -4
- data/try/unit/horreum/unique_index_guard_validation_try.rb +1 -1
- metadata +36 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 217726d80e8424a761dfa3aa0f565e9cdadd3da409654f5077226e1a85ae29ac
|
|
4
|
+
data.tar.gz: b9ce4a30717b220b78689332da5fe40fe790535029399fa2b0f9978dabd89ff5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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:
|
|
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:
|
|
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 =
|
|
554
|
+
start_time = Familia.now
|
|
555
555
|
result = original_encrypt.call(data, **opts)
|
|
556
|
-
total_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_#{
|
|
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 =
|
|
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 =
|
|
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 + (
|
|
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:
|
|
248
|
+
timestamp: Familia.now.to_f
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
self.class.audit_log.append(change_record.to_json)
|
data/docs/guides/field-system.md
CHANGED
|
@@ -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 =
|
|
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 =
|
|
644
|
-
expect(instance.created_at).to be_a(
|
|
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 =
|
|
469
|
+
start = Familia.now
|
|
470
470
|
domains = Domain.load_multi(domain_ids).compact
|
|
471
|
-
duration =
|
|
471
|
+
duration = Familia.now - start
|
|
472
472
|
Rails.logger.info "Loaded #{domains.size} domains in #{duration}s"
|
|
473
473
|
```
|
|
474
474
|
|
|
@@ -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 =
|
|
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 =
|
|
148
|
+
no_cache_time = Familia.now - start_time
|
|
149
149
|
|
|
150
150
|
# With caching - reuses derived keys within the block
|
|
151
|
-
start_time =
|
|
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 =
|
|
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:
|
|
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:
|
|
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',
|
|
220
|
+
# conn.hset(obj.dbkey, 'updated_at', Familia.now.to_i)
|
|
221
221
|
# end
|
|
222
222
|
#
|
|
223
223
|
# @note Connection Inheritance:
|
|
@@ -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.
|
|
98
|
-
Familia.
|
|
99
|
-
Familia.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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)
|