familia 2.2.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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +132 -0
  3. data/CLAUDE.md +84 -0
  4. data/Gemfile +3 -3
  5. data/Gemfile.lock +17 -8
  6. data/README.md +1 -1
  7. data/docs/guides/feature-encrypted-fields.md +4 -4
  8. data/docs/guides/feature-relationships-indexing.md +1 -1
  9. data/docs/guides/feature-relationships-participation.md +1 -1
  10. data/docs/guides/feature-relationships.md +1 -1
  11. data/docs/guides/feature-system.md +1 -1
  12. data/docs/guides/field-system.md +3 -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 +1 -1
  20. data/lib/familia/data_type/types/counter.rb +14 -5
  21. data/lib/familia/data_type/types/hashkey.rb +14 -2
  22. data/lib/familia/data_type/types/json_stringkey.rb +3 -1
  23. data/lib/familia/data_type/types/listkey.rb +20 -3
  24. data/lib/familia/data_type/types/sorted_set.rb +24 -8
  25. data/lib/familia/data_type/types/stringkey.rb +4 -0
  26. data/lib/familia/data_type/types/unsorted_set.rb +16 -3
  27. data/lib/familia/data_type.rb +123 -1
  28. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +5 -0
  29. data/lib/familia/features/expiration.rb +104 -13
  30. data/lib/familia/features/relationships/participation.rb +2 -2
  31. data/lib/familia/field_type.rb +14 -1
  32. data/lib/familia/horreum/connection.rb +3 -3
  33. data/lib/familia/horreum/definition.rb +7 -0
  34. data/lib/familia/horreum/dirty_tracking.rb +115 -0
  35. data/lib/familia/horreum/management/audit.rb +395 -0
  36. data/lib/familia/horreum/management/audit_report.rb +104 -0
  37. data/lib/familia/horreum/management/repair.rb +376 -0
  38. data/lib/familia/horreum/management.rb +153 -22
  39. data/lib/familia/horreum/persistence.rb +257 -19
  40. data/lib/familia/horreum/serialization.rb +34 -0
  41. data/lib/familia/horreum.rb +7 -0
  42. data/lib/familia/logging.rb +1 -1
  43. data/lib/familia/settings.rb +21 -1
  44. data/lib/familia/thread_safety/monitor.rb +4 -4
  45. data/lib/familia/version.rb +1 -1
  46. data/lib/middleware/database_logger.rb +4 -4
  47. data/try/audit/audit_instances_try.rb +119 -0
  48. data/try/audit/audit_report_try.rb +182 -0
  49. data/try/audit/audit_unique_indexes_try.rb +92 -0
  50. data/try/audit/compound_identifier_try.rb +119 -0
  51. data/try/audit/health_check_try.rb +101 -0
  52. data/try/audit/m2_rebuild_batch_try.rb +110 -0
  53. data/try/audit/m3_multi_index_stub_try.rb +175 -0
  54. data/try/audit/m5_repair_batched_try.rb +144 -0
  55. data/try/audit/participation_audit_try.rb +121 -0
  56. data/try/audit/participation_instance_audit_try.rb +206 -0
  57. data/try/audit/participation_list_audit_try.rb +187 -0
  58. data/try/audit/participation_set_audit_try.rb +101 -0
  59. data/try/audit/rebuild_instances_try.rb +117 -0
  60. data/try/audit/repair_all_integration_try.rb +150 -0
  61. data/try/audit/repair_all_try.rb +84 -0
  62. data/try/audit/repair_indexes_try.rb +77 -0
  63. data/try/audit/repair_instances_try.rb +94 -0
  64. data/try/audit/repair_participations_try.rb +182 -0
  65. data/try/audit/repair_score_correctness_try.rb +139 -0
  66. data/try/audit/scan_keys_try.rb +66 -0
  67. data/try/edge_cases/find_by_dbkey_race_condition_try.rb +66 -76
  68. data/try/features/atomic_counter_try.rb +134 -0
  69. data/try/features/atomicity_try.rb +138 -0
  70. data/try/features/count_any_edge_cases_try.rb +4 -4
  71. data/try/features/dirty_tracking_try.rb +595 -0
  72. data/try/features/expiration_cascade_try.rb +136 -0
  73. data/try/features/field_groups_try.rb +2 -2
  74. data/try/features/ghost_objects_try.rb +124 -0
  75. data/try/features/instance_registry_try.rb +112 -0
  76. data/try/features/load_missing_keys_try.rb +112 -0
  77. data/try/features/registry_methods_try.rb +125 -0
  78. data/try/features/relationships/participation_commands_verification_spec.rb +6 -7
  79. data/try/features/relationships/participation_commands_verification_try.rb +3 -3
  80. data/try/features/relationships/participation_performance_improvements_try.rb +1 -1
  81. data/try/features/relationships/participation_reverse_index_try.rb +2 -2
  82. data/try/features/relationships/participation_target_class_resolution_try.rb +2 -2
  83. data/try/features/relationships/relationships_performance_try.rb +4 -1
  84. data/try/features/safe_dump/safe_dump_advanced_try.rb +1 -1
  85. data/try/features/save_with_collections_try.rb +75 -0
  86. data/try/features/serialization_debug_try.rb +159 -0
  87. data/try/features/ttl_report_try.rb +183 -0
  88. data/try/integration/connection/transaction_fallback_integration_try.rb +1 -1
  89. data/try/integration/connection/transaction_mode_permissive_try.rb +1 -1
  90. data/try/integration/models/customer_try.rb +4 -4
  91. data/try/integration/relationships_persistence_round_trip_try.rb +1 -1
  92. data/try/integration/transaction_safety_core_try.rb +1 -1
  93. data/try/integration/transaction_safety_workflow_try.rb +11 -11
  94. data/try/migration/integration_try.rb +1 -1
  95. data/try/migration/model_try.rb +1 -1
  96. data/try/migration/pipeline_try.rb +2 -2
  97. data/try/migration/registry_try.rb +1 -1
  98. data/try/migration/runner_try.rb +1 -1
  99. data/try/migration/v1_to_v2_serialization_try.rb +2 -2
  100. data/try/performance/transaction_safety_benchmark_try.rb +20 -20
  101. data/try/support/benchmarks/deserialization_benchmark.rb +1 -1
  102. data/try/support/benchmarks/deserialization_correctness_test.rb +1 -1
  103. data/try/unit/data_types/counter_try.rb +2 -2
  104. data/try/unit/horreum/serialization_try.rb +4 -4
  105. data/try/unit/horreum/unique_index_guard_validation_try.rb +1 -1
  106. metadata +36 -3
  107. data/pr_agent.toml +0 -36
  108. data/pr_compliance_checklist.yaml +0 -45
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5608f472e264e8cbf6228e398d4a25517173e55ad0d7d10a2b74de31121b18f
4
- data.tar.gz: 665996a35ca541c9884453c4c664d197b865bdba888e424cdd8a5a555df96a37
3
+ metadata.gz: 87748023c0bec120fe0b4936eac184ebe7f72651639b7ff87238782987654cc7
4
+ data.tar.gz: 5a3cbaf9000cf1e12cdcff9f2d35eecca57a010725e501de37485bda7cf214af
5
5
  SHA512:
6
- metadata.gz: 22c1c274df0683053f768b418e7bc239fbc31fce4decf405af2e87c0b60f6f0c365c7dd88ba7a897746fffd785bdf15cfba71549ea8f32a0cdbf054eeb2467fc
7
- data.tar.gz: b8bbe2ec2153c5de43b98bf9e03e80e94d494d056bbb0b65711f20b76fb77b99f5847be4b5420ccce5497ef18fb29dd38fd7f8329b67ce4928dff6d6b43546d4
6
+ metadata.gz: 21847a2154326c84d70557ee62ecfc4ea960f74ae6358e41b6c1d6fb7012d58034256a35dbc9aae26778a179cc3524e54012933a84191d2dc570f9d73c400cb8
7
+ data.tar.gz: da077f52d0ecbeba97779470f1e6d3a51934e7e7e3e03b05005e1187794f3ef23c9386aa15fe3747113c31982bf6e56add41948bd78a0d6d2867abaef883a31b
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,138 @@ 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
+
47
+ .. _changelog-2.3.0:
48
+
49
+ 2.3.0 — 2026-02-26
50
+ ==================
51
+
52
+ Added
53
+ -----
54
+
55
+ - ``touch_instances!`` and ``remove_from_instances!`` instance methods for
56
+ explicit instances timeline management. ``touch_instances!`` is idempotent
57
+ (ZADD updates the timestamp without duplicating).
58
+
59
+ - ``in_instances?(identifier)`` class method for O(log N) membership checks
60
+ against the ``instances`` sorted set without loading the object.
61
+
62
+ - Dirty tracking for scalar fields: ``dirty?``, ``dirty_fields``,
63
+ ``changed_fields``, ``clear_dirty!``. Setters automatically mark fields
64
+ dirty; state is cleared after ``save``, ``commit_fields``, and ``refresh!``.
65
+
66
+ - ``warn_if_dirty!`` guard on collection write methods (``add``, ``push``,
67
+ ``[]=``, ``value=``). Warns when the parent Horreum has unsaved scalar
68
+ changes. Enable ``Familia.strict_write_order = true`` to raise instead.
69
+
70
+ - ``ttl_report`` instance method on Expiration-enabled models. Returns a hash
71
+ showing TTL for the main key and all relation keys, useful for detecting
72
+ TTL drift.
73
+
74
+ - ``debug_fields`` instance method on Horreum. Returns a diagnostic hash
75
+ showing Ruby value, stored JSON, and type for each persistent field.
76
+
77
+ - Proactive consistency audit infrastructure for Horreum models. Every
78
+ subclass now has ``health_check``, ``audit_instances``,
79
+ ``audit_unique_indexes``, ``audit_multi_indexes``, and
80
+ ``audit_participations`` class methods to detect phantoms (timeline
81
+ entries without backing keys), missing entries (keys not in timeline),
82
+ stale index entries, and orphaned participation members. Issue #221.
83
+
84
+ - ``AuditReport`` data structure (``Data.define``) that wraps audit
85
+ results with ``healthy?``, ``to_h`` (summary counts), and ``to_s``
86
+ (human-readable) methods for quick inspection and programmatic use.
87
+
88
+ - Repair and rebuild operations: ``repair_instances!``,
89
+ ``rebuild_instances``, ``repair_indexes!``,
90
+ ``repair_participations!``, and ``repair_all!`` class methods.
91
+ ``rebuild_instances`` performs a full SCAN-based rebuild with atomic
92
+ swap via the existing ``RebuildStrategies`` infrastructure.
93
+
94
+ - ``scan_keys`` helper on ManagementMethods for production-safe
95
+ enumeration of keys matching a class pattern via SCAN.
96
+
97
+ - Participation audit reads actual collection contents (not the instances
98
+ timeline) and repairs use TYPE introspection to dispatch the correct
99
+ removal command per collection type.
100
+
101
+ Changed
102
+ -------
103
+
104
+ - ``find_by_dbkey`` and ``find_by_identifier`` are now read-only.
105
+ They no longer call ``cleanup_stale_instance_entry`` as a side effect
106
+ when a key is missing. Ghost cleanup is the explicit responsibility
107
+ of the audit/repair layer or direct caller invocation.
108
+ ``cleanup_stale_instance_entry`` is now a public class method.
109
+
110
+ - Fast writers (``field!``), ``batch_update``, ``batch_fast_write``,
111
+ and ``save_fields`` now clear dirty tracking state after a successful
112
+ database write. Note: this currently clears all dirty flags, even for
113
+ fields that were not part of the partial write. This known limitation
114
+ is documented in ``try/features/dirty_tracking_try.rb`` and will be
115
+ addressed in a future release.
116
+
117
+ Fixed
118
+ -----
119
+
120
+ - ``commit_fields``, ``batch_update``, ``save_fields``, and fast writers now
121
+ touch the ``instances`` sorted set via ``touch_instances!``.
122
+ Previously, only ``save`` updated the timeline, leaving objects created
123
+ through other write paths invisible to ``instances.to_a`` enumeration.
124
+
125
+ - Class-level ``destroy!`` now removes the identifier from the ``instances``
126
+ sorted set, preventing ghost entries after deletion.
127
+
128
+ Documentation
129
+ -------------
130
+
131
+ - Added serialization encoding guide to CLAUDE.md showing how each DataType
132
+ serializes values and what raw Redis output looks like per type.
133
+
134
+ AI Assistance
135
+ -------------
136
+
137
+ - Implementation, test authoring, and iterative debugging performed with
138
+ Claude Opus 4.6 assistance across dirty tracking, write-order guards,
139
+ TTL reporting, debug_fields, audit/repair infrastructure, and 211 test
140
+ cases across 14 audit files.
141
+
10
142
  .. _changelog-2.2.0:
11
143
 
12
144
  2.2.0 — 2026-02-23
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 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.2.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
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)
@@ -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
 
@@ -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
 
@@ -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)
@@ -413,7 +413,7 @@ class AuditedFieldType < Familia::FieldType
413
413
 
414
414
  # Audit the change
415
415
  old_value = hget(field_name)
416
- timestamp = Time.now.to_i
416
+ timestamp = Familia.now.to_i
417
417
 
418
418
  # Log the change
419
419
  puts "AUDIT: #{field_name} changed from #{old_value} to #{val} at #{timestamp}"
@@ -677,8 +677,8 @@ describe TimestampFieldType do
677
677
  expect(instance.created_at).to be_a(Time)
678
678
  expect(instance.created_at.to_s).to include("2024-01-01 12:00:00")
679
679
 
680
- instance.created_at = Time.now
681
- expect(instance.created_at).to be_a(Time)
680
+ instance.created_at = Familia.now
681
+ expect(instance.created_at).to be_a(Familia)
682
682
  end
683
683
 
684
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
@@ -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
@@ -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)
@@ -19,7 +19,12 @@ module Familia
19
19
 
20
20
  # +return+ [Integer] Returns 1 if the field is new and added, 0 if the
21
21
  # field already existed and the value was updated.
22
+ #
23
+ # @note This method executes a Redis HSET immediately, unlike scalar field
24
+ # setters which are deferred until save. If the parent object has unsaved
25
+ # scalar field changes, consider calling save first to avoid split-brain state.
22
26
  def []=(field, val)
27
+ warn_if_dirty!
23
28
  ret = dbclient.hset dbkey, field.to_s, serialize_value(val)
24
29
  update_expiration
25
30
  ret
@@ -72,6 +77,7 @@ module Familia
72
77
  # @param val [Object] The value to set
73
78
  # @return [Integer] 1 if field is a new field and value was set, 0 if field already exists
74
79
  def hsetnx(field, val)
80
+ warn_if_dirty!
75
81
  ret = dbclient.hsetnx dbkey, field.to_s, serialize_value(val)
76
82
  update_expiration if ret == 1
77
83
  ret
@@ -95,13 +101,18 @@ module Familia
95
101
  # @param field [String] The field to remove
96
102
  # @return [Integer] The number of fields that were removed (0 or 1)
97
103
  def remove_field(field)
98
- dbclient.hdel dbkey, field.to_s
104
+ warn_if_dirty!
105
+ ret = dbclient.hdel dbkey, field.to_s
106
+ update_expiration
107
+ ret
99
108
  end
100
109
  alias remove remove_field
101
110
  alias remove_element remove_field
102
111
 
103
112
  def increment(field, by = 1)
104
- dbclient.hincrby(dbkey, field.to_s, by).to_i
113
+ ret = dbclient.hincrby(dbkey, field.to_s, by).to_i
114
+ update_expiration
115
+ ret
105
116
  end
106
117
  alias incr increment
107
118
  alias incrby increment
@@ -113,6 +124,7 @@ module Familia
113
124
  alias decrby decrement
114
125
 
115
126
  def update(hsh = {})
127
+ warn_if_dirty!
116
128
  raise ArgumentError, 'Argument to bulk_set must be a hash' unless hsh.is_a?(Hash)
117
129
 
118
130
  data = hsh.inject([]) { |ret, pair| ret << [pair[0], serialize_value(pair[1])] }.flatten