familia 2.3.2 → 2.4.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 +81 -0
- data/Gemfile.lock +1 -1
- data/docs/guides/feature-expiration.md +1 -1
- data/docs/guides/feature-relationships-indexing.md +39 -28
- data/docs/guides/feature-relationships-participation.md +74 -0
- data/lib/familia/encryption/manager.rb +7 -3
- data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +11 -9
- data/lib/familia/features/expiration.rb +15 -0
- data/lib/familia/features/relationships/participation/staged_operations.rb +239 -0
- data/lib/familia/features/relationships/participation/target_methods.rb +228 -10
- data/lib/familia/features/relationships/participation/through_model_operations.rb +2 -2
- data/lib/familia/features/relationships/participation.rb +15 -2
- data/lib/familia/features/relationships/participation_relationship.rb +15 -0
- data/lib/familia/horreum/persistence.rb +5 -6
- data/lib/familia/version.rb +1 -1
- data/try/features/encrypted_fields/aad_nil_fields_try.rb +114 -0
- data/try/features/encrypted_fields/aad_protection_try.rb +12 -12
- data/try/features/encrypted_fields/aad_roundtrip_try.rb +102 -0
- data/try/features/encrypted_fields/fast_writer_try.rb +69 -0
- data/try/features/encryption/encoding_phase1_try.rb +21 -21
- data/try/features/encryption/encoding_phase2_try.rb +108 -0
- data/try/features/expiration/expiration_try.rb +22 -0
- data/try/features/expiration_cascade_try.rb +36 -0
- data/try/features/relationships/staged_relationships_try.rb +852 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9dfc0a904fa0458350fcdc3098b01e4ecbbe12846b9ddcb2265929d1f05f3a3d
|
|
4
|
+
data.tar.gz: ecd74f3b069bde39291fa5c750ed7a4d6f38938f4514563fd87309380b711cba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad072a725504dfe12f19ebe23ff8d257174ae431f8a42bf654e17e14db55f0361811e896b49d9f48eeb137272bcce991ddb65c813b0591379eddf9fffc6dd566
|
|
7
|
+
data.tar.gz: c74fb2bda18b647f1c32d81e669f275c1a93c88aa0123ff69258262cd3ef31bb14b9fda8b31f57fbd9f96eb188f89bed0435a05d844e2f5cab77bd7ff7e1c822
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,87 @@ 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.4.0:
|
|
11
|
+
|
|
12
|
+
2.4.0 — 2026-04-06
|
|
13
|
+
==================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- Added ``staged:`` option to ``participates_in`` for invitation workflows where
|
|
19
|
+
through models must exist before participants. Creates a staging sorted set
|
|
20
|
+
alongside the active membership set with three new operations:
|
|
21
|
+
``stage_members_instance``, ``activate_members_instance``, ``unstage_members_instance``.
|
|
22
|
+
Staged models use UUID keys; activated models use composite keys.
|
|
23
|
+
(`#237 <https://github.com/delano/familia/issues/237>`_)
|
|
24
|
+
|
|
25
|
+
- Added ``StagedOperations`` module in ``lib/familia/features/relationships/participation/``
|
|
26
|
+
for staging lifecycle management with lazy cleanup for ghost entries.
|
|
27
|
+
|
|
28
|
+
- Added ``staged?`` and ``staging_collection_name`` methods to ``ParticipationRelationship``.
|
|
29
|
+
|
|
30
|
+
Changed
|
|
31
|
+
-------
|
|
32
|
+
|
|
33
|
+
- **Breaking change**: Through models in staged relationships use UUID keys during staging,
|
|
34
|
+
composite keys after activation. The staged model is destroyed during activation --
|
|
35
|
+
any references to it become invalid. Application code calling ``accept!`` on
|
|
36
|
+
staged memberships should capture and use the returned activated model rather
|
|
37
|
+
than the original staged model.
|
|
38
|
+
|
|
39
|
+
- Extended ``participates_in`` signature to accept ``staged:`` option (Symbol or nil).
|
|
40
|
+
Validation ensures ``staged:`` requires ``through:`` option.
|
|
41
|
+
|
|
42
|
+
AI Assistance
|
|
43
|
+
-------------
|
|
44
|
+
|
|
45
|
+
- Claude assisted with architecture design, identifying the impedance mismatch between
|
|
46
|
+
relational ORM patterns and Redis's materialized indexes, analyzing transaction
|
|
47
|
+
boundaries, and designing the separation between ``StagedOperations`` and
|
|
48
|
+
``ThroughModelOperations`` modules.
|
|
49
|
+
|
|
50
|
+
.. _changelog-2.3.3:
|
|
51
|
+
|
|
52
|
+
2.3.3 — 2026-03-30
|
|
53
|
+
==================
|
|
54
|
+
|
|
55
|
+
Added
|
|
56
|
+
-----
|
|
57
|
+
|
|
58
|
+
- Encrypt now records the original plaintext encoding in the EncryptedData
|
|
59
|
+
envelope (``encoding`` field), completing the Phase 2 encoding round-trip
|
|
60
|
+
fix. Decrypt (Phase 1, #228) already falls back to UTF-8 when the field
|
|
61
|
+
is absent, so this change is backward-compatible. PR #229
|
|
62
|
+
|
|
63
|
+
Fixed
|
|
64
|
+
-----
|
|
65
|
+
|
|
66
|
+
- ``build_aad`` now produces consistent AAD (Additional Authenticated Data)
|
|
67
|
+
regardless of whether the record has been persisted. Previously, encrypted
|
|
68
|
+
fields with ``aad_fields`` used different AAD computation paths before and
|
|
69
|
+
after save, making ``reveal`` fail on any record created via ``create!``.
|
|
70
|
+
Issue #232, PR #234. No migration needed — the previous behavior was
|
|
71
|
+
broken (AAD mismatch prevented decryption), so no valid ciphertexts
|
|
72
|
+
exist under the old inconsistent paths.
|
|
73
|
+
|
|
74
|
+
- ``build_aad`` no longer uses ``.compact`` on AAD field values. Previously,
|
|
75
|
+
nil fields were silently dropped, shifting later values left and producing
|
|
76
|
+
a different hash once the field was populated. Now each field is coerced
|
|
77
|
+
via ``.to_s`` so that nil and empty string both occupy a fixed position
|
|
78
|
+
in the joined AAD string. Issue #232, PR #234.
|
|
79
|
+
|
|
80
|
+
AI Assistance
|
|
81
|
+
-------------
|
|
82
|
+
|
|
83
|
+
- Implementation and test authoring delegated to backend-dev agents, with
|
|
84
|
+
orchestration and Phase 1 test fixups handled in the main session. Claude
|
|
85
|
+
Opus 4.6.
|
|
86
|
+
|
|
87
|
+
- Claude assisted with implementing the fix, updating affected tests, and
|
|
88
|
+
writing the round-trip regression test. The issue analysis, root cause
|
|
89
|
+
identification, and suggested fix were provided in the issue by the author.
|
|
90
|
+
|
|
10
91
|
.. _changelog-2.3.2:
|
|
11
92
|
|
|
12
93
|
2.3.2 — 2026-03-12
|
data/Gemfile.lock
CHANGED
|
@@ -5,6 +5,7 @@ Indexing provides O(1) field-to-object lookups using Redis data structures, enab
|
|
|
5
5
|
## Core Concepts
|
|
6
6
|
|
|
7
7
|
Indexing creates fast lookups for finding objects by field values:
|
|
8
|
+
|
|
8
9
|
- **O(1) performance** - Hash/Set-based constant-time access
|
|
9
10
|
- **Automatic management** - Class indexes update on save/destroy
|
|
10
11
|
- **Flexible scoping** - Global or parent-scoped uniqueness
|
|
@@ -12,12 +13,12 @@ Indexing creates fast lookups for finding objects by field values:
|
|
|
12
13
|
|
|
13
14
|
## Index Types
|
|
14
15
|
|
|
15
|
-
| Type
|
|
16
|
-
|
|
17
|
-
| `unique_index` | Class
|
|
18
|
-
| `unique_index` | Instance
|
|
19
|
-
| `multi_index`
|
|
20
|
-
| `multi_index`
|
|
16
|
+
| Type | Scope | Use Case | Structure |
|
|
17
|
+
| -------------- | --------------- | --------------------------- | ------------------- |
|
|
18
|
+
| `unique_index` | Class | Global unique fields | Redis HashKey |
|
|
19
|
+
| `unique_index` | Instance | Parent-scoped unique | Redis HashKey |
|
|
20
|
+
| `multi_index` | Class (default) | Global non-unique groupings | Redis Set per value |
|
|
21
|
+
| `multi_index` | Instance | Parent-scoped groupings | Redis Set per value |
|
|
21
22
|
|
|
22
23
|
## Class-Level Unique Indexing
|
|
23
24
|
|
|
@@ -47,12 +48,12 @@ User.find_by_email('alice.smith@example.com') # => nil
|
|
|
47
48
|
|
|
48
49
|
### Generated Methods
|
|
49
50
|
|
|
50
|
-
| Method
|
|
51
|
-
|
|
52
|
-
| `User.find_by_email(email)`
|
|
53
|
-
| `User.index_email_for(user)`
|
|
51
|
+
| Method | Description |
|
|
52
|
+
| ------------------------------ | ----------------- |
|
|
53
|
+
| `User.find_by_email(email)` | O(1) lookup |
|
|
54
|
+
| `User.index_email_for(user)` | Manual index |
|
|
54
55
|
| `User.unindex_email_for(user)` | Remove from index |
|
|
55
|
-
| `User.reindex_email_for(user)` | Update index
|
|
56
|
+
| `User.reindex_email_for(user)` | Update index |
|
|
56
57
|
|
|
57
58
|
## Instance-Scoped Unique Indexing
|
|
58
59
|
|
|
@@ -139,20 +140,20 @@ Customer.role_index_for('user').dbkey # => "customer:role_index:user"
|
|
|
139
140
|
|
|
140
141
|
### Generated Class Methods
|
|
141
142
|
|
|
142
|
-
| Method
|
|
143
|
-
|
|
144
|
-
| `Customer.role_index_for(value)`
|
|
145
|
-
| `Customer.find_all_by_role(value)`
|
|
146
|
-
| `Customer.sample_from_role(value, count)` | Random sample of objects
|
|
147
|
-
| `Customer.rebuild_role_index`
|
|
143
|
+
| Method | Description |
|
|
144
|
+
| ----------------------------------------- | ------------------------------------------------------------ |
|
|
145
|
+
| `Customer.role_index_for(value)` | Factory returning `Familia::UnsortedSet` for the field value |
|
|
146
|
+
| `Customer.find_all_by_role(value)` | Find all objects with that field value |
|
|
147
|
+
| `Customer.sample_from_role(value, count)` | Random sample of objects |
|
|
148
|
+
| `Customer.rebuild_role_index` | Rebuild the entire index from source data |
|
|
148
149
|
|
|
149
150
|
### Generated Instance Methods
|
|
150
151
|
|
|
151
|
-
| Method
|
|
152
|
-
|
|
153
|
-
| `customer.add_to_class_role_index`
|
|
154
|
-
| `customer.remove_from_class_role_index`
|
|
155
|
-
| `customer.update_in_class_role_index(old_value)` | Move object from old index to new index
|
|
152
|
+
| Method | Description |
|
|
153
|
+
| ------------------------------------------------ | ----------------------------------------------- |
|
|
154
|
+
| `customer.add_to_class_role_index` | Add this object to its field value's index |
|
|
155
|
+
| `customer.remove_from_class_role_index` | Remove this object from its field value's index |
|
|
156
|
+
| `customer.update_in_class_role_index(old_value)` | Move object from old index to new index |
|
|
156
157
|
|
|
157
158
|
### Update Operations
|
|
158
159
|
|
|
@@ -271,24 +272,28 @@ todays_events = user.find_all_by_daily_partition(today)
|
|
|
271
272
|
### Class vs Instance Scoping
|
|
272
273
|
|
|
273
274
|
**Class-level unique (`unique_index :email, :email_lookup`):**
|
|
275
|
+
|
|
274
276
|
- Automatic indexing on save/destroy
|
|
275
277
|
- System-wide uniqueness
|
|
276
278
|
- No parent context needed
|
|
277
279
|
- Examples: emails, usernames, API keys
|
|
278
280
|
|
|
279
281
|
**Class-level multi (`multi_index :role, :role_index`):**
|
|
282
|
+
|
|
280
283
|
- Default behavior (no `within:` needed)
|
|
281
284
|
- Groups all objects by field value at class level
|
|
282
285
|
- Manual indexing via instance methods
|
|
283
286
|
- Examples: roles, categories, statuses
|
|
284
287
|
|
|
285
288
|
**Instance-scoped (`unique_index :badge, :badge_index, within: Company`):**
|
|
289
|
+
|
|
286
290
|
- Manual indexing required
|
|
287
291
|
- Unique within parent only
|
|
288
292
|
- Requires parent context
|
|
289
293
|
- Examples: employee IDs, project names per team
|
|
290
294
|
|
|
291
295
|
**Instance-scoped multi (`multi_index :dept, :dept_index, within: Company`):**
|
|
296
|
+
|
|
292
297
|
- Groups objects by field value within parent scope
|
|
293
298
|
- Same field value allowed across different parents
|
|
294
299
|
- Manual indexing with parent context
|
|
@@ -297,11 +302,13 @@ todays_events = user.find_all_by_daily_partition(today)
|
|
|
297
302
|
### Unique vs Multi Indexing
|
|
298
303
|
|
|
299
304
|
**Unique index (`unique_index`):**
|
|
305
|
+
|
|
300
306
|
- 1:1 field-to-object mapping
|
|
301
307
|
- Returns single object or nil
|
|
302
308
|
- Enforces uniqueness within scope
|
|
303
309
|
|
|
304
310
|
**Multi index (`multi_index`):**
|
|
311
|
+
|
|
305
312
|
- 1:many field-to-objects mapping
|
|
306
313
|
- Returns array of objects
|
|
307
314
|
- Allows duplicate values
|
|
@@ -325,6 +332,7 @@ These methods work because **indexes are derived data** - they're computed from
|
|
|
325
332
|
> **Important:** Participation data (like `@team.members`) cannot be rebuilt automatically because participations represent business decisions, not derived data. See [Why Participations Can't Be Rebuilt](../../lib/familia/features/relationships/participation/rebuild_strategies.md) for the critical distinction between indexes and participations.
|
|
326
333
|
|
|
327
334
|
**When to rebuild indexes:**
|
|
335
|
+
|
|
328
336
|
- After data migrations or bulk imports
|
|
329
337
|
- Recovering from index corruption
|
|
330
338
|
- Adding indexes to existing data
|
|
@@ -372,26 +380,29 @@ Index values (the object identifiers stored in hash keys and sets) are raw strin
|
|
|
372
380
|
|
|
373
381
|
## Redis Key Patterns
|
|
374
382
|
|
|
375
|
-
| Type
|
|
376
|
-
|
|
377
|
-
| Class unique
|
|
378
|
-
| Class multi
|
|
379
|
-
| Instance unique | `{scope}:{id}:{index_name}`
|
|
380
|
-
| Instance multi
|
|
383
|
+
| Type | Pattern | Example |
|
|
384
|
+
| --------------- | ----------------------------------- | ------------------------------------ |
|
|
385
|
+
| Class unique | `{class}:{index_name}` | `user:email_lookup` |
|
|
386
|
+
| Class multi | `{class}:{index_name}:{value}` | `customer:role_index:admin` |
|
|
387
|
+
| Instance unique | `{scope}:{id}:{index_name}` | `company:123:badge_index` |
|
|
388
|
+
| Instance multi | `{scope}:{id}:{index_name}:{value}` | `company:123:dept_index:engineering` |
|
|
381
389
|
|
|
382
390
|
## Troubleshooting
|
|
383
391
|
|
|
384
392
|
### Common Issues
|
|
385
393
|
|
|
386
394
|
**Query methods not generated:**
|
|
395
|
+
|
|
387
396
|
- Check `query: true` (default) or explicitly set
|
|
388
397
|
- Verify `feature :relationships` declared
|
|
389
398
|
|
|
390
399
|
**Index not updating:**
|
|
400
|
+
|
|
391
401
|
- Class indexes: automatic on save/destroy
|
|
392
402
|
- Instance indexes: require manual `add_to_*` calls
|
|
393
403
|
|
|
394
404
|
**Duplicate key errors:**
|
|
405
|
+
|
|
395
406
|
- Use `multi_index` for non-unique values
|
|
396
407
|
- Consider instance-scoped for contextual uniqueness
|
|
397
408
|
|
|
@@ -189,6 +189,80 @@ def add_members_instance(user, score = nil)
|
|
|
189
189
|
end
|
|
190
190
|
```
|
|
191
191
|
|
|
192
|
+
## Staged Relationships (Invitation Workflows)
|
|
193
|
+
|
|
194
|
+
Staged relationships enable deferred activation where the through model exists before the participant. Common use case: invitations where a Membership record exists before the invited user accepts.
|
|
195
|
+
|
|
196
|
+
### Setup
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Membership < Familia::Horreum
|
|
200
|
+
feature :object_identifier # Required for UUID-keyed staging
|
|
201
|
+
field :organization_objid
|
|
202
|
+
field :customer_objid
|
|
203
|
+
field :email # Invitation email
|
|
204
|
+
field :role
|
|
205
|
+
field :status
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
class Customer < Familia::Horreum
|
|
209
|
+
participates_in Organization, :members,
|
|
210
|
+
through: Membership,
|
|
211
|
+
staged: :pending_members # Enables staging API
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Lifecycle
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# Stage (send invitation)
|
|
219
|
+
membership = org.stage_members_instance(
|
|
220
|
+
through_attrs: { email: 'invite@example.com', role: 'viewer' }
|
|
221
|
+
)
|
|
222
|
+
# → UUID-keyed Membership in pending_members sorted set
|
|
223
|
+
|
|
224
|
+
# Activate (accept invitation)
|
|
225
|
+
activated = org.activate_members_instance(
|
|
226
|
+
membership, customer,
|
|
227
|
+
through_attrs: { status: 'active' }
|
|
228
|
+
)
|
|
229
|
+
# → Composite-keyed Membership, staged model destroyed
|
|
230
|
+
|
|
231
|
+
# Unstage (revoke invitation)
|
|
232
|
+
org.unstage_members_instance(membership)
|
|
233
|
+
# → Membership destroyed, removed from staging set
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Attribute Handling on Activation
|
|
237
|
+
|
|
238
|
+
Activation intentionally does **not** auto-merge attributes from the staged model. The application controls what data carries over:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# Explicit attribute carryover
|
|
242
|
+
activated = org.activate_members_instance(
|
|
243
|
+
staged, customer,
|
|
244
|
+
through_attrs: staged.to_h.slice(:role, :invited_by).merge(status: 'active')
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
This design supports workflows where:
|
|
249
|
+
- Staged data (invitation metadata) differs from activated data (membership settings)
|
|
250
|
+
- Certain fields should reset on activation (e.g., `status`, timestamps)
|
|
251
|
+
- Sensitive staging data should not leak to the activated record
|
|
252
|
+
|
|
253
|
+
### Key Differences from Regular Participation
|
|
254
|
+
|
|
255
|
+
| Aspect | Regular | Staged |
|
|
256
|
+
|--------|---------|--------|
|
|
257
|
+
| Key type | Composite (target+participant) | UUID during staging |
|
|
258
|
+
| Participant | Required at creation | Set on activation |
|
|
259
|
+
| Through model | Optional | Required |
|
|
260
|
+
| Lifecycle | Single-phase | Two-phase (stage → activate) |
|
|
261
|
+
|
|
262
|
+
### Ghost Entry Cleanup
|
|
263
|
+
|
|
264
|
+
Staged models that expire via TTL or are manually deleted leave "ghost entries" in the staging set. These are cleaned lazily when accessed via `load_staged` or enumeration methods.
|
|
265
|
+
|
|
192
266
|
## Performance Best Practices
|
|
193
267
|
|
|
194
268
|
### Bulk Operations
|
|
@@ -15,7 +15,8 @@ module Familia
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def encrypt(plaintext, context:, additional_data: nil)
|
|
18
|
-
|
|
18
|
+
plaintext = plaintext.to_s
|
|
19
|
+
return nil if plaintext.empty?
|
|
19
20
|
|
|
20
21
|
key = derive_key(context)
|
|
21
22
|
|
|
@@ -26,7 +27,8 @@ module Familia
|
|
|
26
27
|
nonce: Base64.strict_encode64(result[:nonce]),
|
|
27
28
|
ciphertext: Base64.strict_encode64(result[:ciphertext]),
|
|
28
29
|
auth_tag: Base64.strict_encode64(result[:auth_tag]),
|
|
29
|
-
key_version: current_key_version
|
|
30
|
+
key_version: current_key_version,
|
|
31
|
+
encoding: plaintext.encoding.name,
|
|
30
32
|
).to_h
|
|
31
33
|
|
|
32
34
|
Familia::JsonSerializer.dump(encrypted_data)
|
|
@@ -35,7 +37,9 @@ module Familia
|
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def decrypt(encrypted_json_or_hash, context:, additional_data: nil)
|
|
38
|
-
|
|
40
|
+
if encrypted_json_or_hash.nil? || (encrypted_json_or_hash.respond_to?(:empty?) && encrypted_json_or_hash.empty?)
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
39
43
|
|
|
40
44
|
# Increment counter immediately to track all decryption attempts, even failed ones
|
|
41
45
|
Familia::Encryption.derivation_count.increment
|
|
@@ -221,16 +221,18 @@ module Familia
|
|
|
221
221
|
if @aad_fields.empty?
|
|
222
222
|
# When no AAD fields specified, use class:field:identifier
|
|
223
223
|
base_components.join(':')
|
|
224
|
-
elsif record.exists?
|
|
225
|
-
# For unsaved records, don't enforce AAD fields since they can change
|
|
226
|
-
# For saved records, include field values for tamper protection
|
|
227
|
-
values = @aad_fields.map { |field| record.send(field) }
|
|
228
|
-
all_components = [*base_components, *values].compact
|
|
229
|
-
Digest::SHA256.hexdigest(all_components.join(':'))
|
|
230
|
-
# Include specified field values in AAD for persisted records
|
|
231
224
|
else
|
|
232
|
-
#
|
|
233
|
-
|
|
225
|
+
# Always include aad_field values regardless of persistence state.
|
|
226
|
+
# The field values are available on the record before save and must
|
|
227
|
+
# produce identical AAD at both encrypt and decrypt time.
|
|
228
|
+
#
|
|
229
|
+
# .to_s coerces nil to "" so that every declared AAD field occupies
|
|
230
|
+
# a fixed position in the join. Without this, a nil field would
|
|
231
|
+
# shift later values left and produce a different hash once the
|
|
232
|
+
# field is populated — making existing ciphertext undecryptable.
|
|
233
|
+
values = @aad_fields.map { |field| record.send(field).to_s }
|
|
234
|
+
all_components = [*base_components, *values]
|
|
235
|
+
Digest::SHA256.hexdigest(all_components.join(':'))
|
|
234
236
|
end
|
|
235
237
|
end
|
|
236
238
|
end
|
|
@@ -385,14 +385,29 @@ module Familia
|
|
|
385
385
|
|
|
386
386
|
# Remove expiration, making the object persist indefinitely
|
|
387
387
|
#
|
|
388
|
+
# Cascades to all related fields by default, following the same
|
|
389
|
+
# pattern as update_expiration. Relations with `no_expiration: true`
|
|
390
|
+
# are skipped (they already persist independently).
|
|
391
|
+
#
|
|
388
392
|
# @return [Boolean] Success of the operation
|
|
389
393
|
#
|
|
390
394
|
# @example Make session persistent
|
|
391
395
|
# session.persist!
|
|
396
|
+
# session.clear_expiration! # alias
|
|
392
397
|
#
|
|
393
398
|
def persist!
|
|
399
|
+
# Cascade to relations first, mirroring update_expiration behavior
|
|
400
|
+
if self.class.relations?
|
|
401
|
+
self.class.related_fields.each do |name, definition|
|
|
402
|
+
next if definition.opts[:no_expiration]
|
|
403
|
+
|
|
404
|
+
send(name).persist
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
394
408
|
dbclient.persist(dbkey)
|
|
395
409
|
end
|
|
410
|
+
alias clear_expiration! persist!
|
|
396
411
|
|
|
397
412
|
# Returns a report of TTL values for the main key and all relation keys.
|
|
398
413
|
#
|