familia 2.3.3 → 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 +40 -0
- data/Gemfile.lock +1 -1
- data/docs/guides/feature-expiration.md +1 -1
- data/docs/guides/feature-relationships-participation.md +74 -0
- 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/version.rb +1 -1
- 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 +3 -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,46 @@ 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
|
+
|
|
10
50
|
.. _changelog-2.3.3:
|
|
11
51
|
|
|
12
52
|
2.3.3 — 2026-03-30
|
data/Gemfile.lock
CHANGED
|
@@ -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
|
|
@@ -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
|
#
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# lib/familia/features/relationships/participation/staged_operations.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'through_model_operations'
|
|
6
|
+
|
|
7
|
+
module Familia
|
|
8
|
+
module Features
|
|
9
|
+
module Relationships
|
|
10
|
+
module Participation
|
|
11
|
+
# StagedOperations provides lifecycle management for staged (deferred) relationships.
|
|
12
|
+
#
|
|
13
|
+
# Staged relationships enable invitation workflows where a through model must exist
|
|
14
|
+
# before the participant does. The staging set holds through model objids until
|
|
15
|
+
# the relationship is activated.
|
|
16
|
+
#
|
|
17
|
+
# Key characteristics:
|
|
18
|
+
# - UUID identifier: Staged models use UUID keys (not composite keys)
|
|
19
|
+
# - Deferred activation: Through model exists before participant
|
|
20
|
+
# - Clean handoff: Activation creates composite-keyed model, destroys UUID-keyed model
|
|
21
|
+
# - Lazy cleanup: Ghost entries (deleted models still in staging set) are cleaned on access
|
|
22
|
+
#
|
|
23
|
+
# Lifecycle:
|
|
24
|
+
# stage → UUID-keyed through model + ZADD staging set
|
|
25
|
+
# activate → composite-keyed through model + ZADD active + SADD participations + cleanup
|
|
26
|
+
# unstage → ZREM staging + destroy through model
|
|
27
|
+
#
|
|
28
|
+
# Example:
|
|
29
|
+
# class Membership < Familia::Horreum
|
|
30
|
+
# feature :object_identifier
|
|
31
|
+
# field :organization_objid
|
|
32
|
+
# field :customer_objid
|
|
33
|
+
# field :email
|
|
34
|
+
# field :role
|
|
35
|
+
# field :status
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# class Customer < Familia::Horreum
|
|
39
|
+
# participates_in Organization, :members,
|
|
40
|
+
# through: Membership,
|
|
41
|
+
# staged: :pending_members
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# # Stage (invitation sent)
|
|
45
|
+
# membership = org.stage_members_instance(
|
|
46
|
+
# through_attrs: { email: 'invite@example.com', role: 'viewer' }
|
|
47
|
+
# )
|
|
48
|
+
#
|
|
49
|
+
# # Activate (invitation accepted) - explicitly carry over desired attributes
|
|
50
|
+
# # Note: attrs are NOT auto-merged from staged model (intentional design)
|
|
51
|
+
# activated = org.activate_members_instance(
|
|
52
|
+
# membership, customer,
|
|
53
|
+
# through_attrs: membership.to_h.slice(:role).merge(status: 'active')
|
|
54
|
+
# )
|
|
55
|
+
#
|
|
56
|
+
module StagedOperations
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# Stage a new through model for deferred activation.
|
|
60
|
+
#
|
|
61
|
+
# Creates a through model with a UUID key (not composite) and sets only the
|
|
62
|
+
# target foreign key. The participant foreign key remains nil until activation.
|
|
63
|
+
#
|
|
64
|
+
# The UUID comes from the through model's `feature :object_identifier` init hook.
|
|
65
|
+
# This is the key difference from ThroughModelOperations.find_or_create, which
|
|
66
|
+
# uses build_key to create a composite key from target and participant.
|
|
67
|
+
#
|
|
68
|
+
# @note Non-atomic operation: The through model is saved before being added to the
|
|
69
|
+
# staging set (by the caller). If the staging set add fails (rare Redis failure),
|
|
70
|
+
# an orphaned through model may exist. The lazy cleanup mechanism in `load_staged`
|
|
71
|
+
# handles such orphans on subsequent access. This trade-off is acceptable for the
|
|
72
|
+
# invitation use case where Redis failures are uncommon.
|
|
73
|
+
#
|
|
74
|
+
# @note The staging set score (set by the caller) represents the creation timestamp.
|
|
75
|
+
# Retrieve via `staging_collection.score(objid)` if needed.
|
|
76
|
+
#
|
|
77
|
+
# @param through_class [Class] The through model class (must have feature :object_identifier)
|
|
78
|
+
# @param target [Object] The target instance (e.g., organization)
|
|
79
|
+
# @param attrs [Hash] Attributes to set on the through model
|
|
80
|
+
# @return [Object] The created through model instance with UUID objid
|
|
81
|
+
#
|
|
82
|
+
def stage(through_class:, target:, attrs: {})
|
|
83
|
+
# Create through model - UUID is auto-generated by object_identifier feature's init hook
|
|
84
|
+
# No objid parameter passed = feature generates UUID (not composite key like find_or_create)
|
|
85
|
+
inst = through_class.new
|
|
86
|
+
|
|
87
|
+
# Set target foreign key only (participant is nil during staging)
|
|
88
|
+
target_field = "#{target.class.config_name}_objid"
|
|
89
|
+
inst.send("#{target_field}=", target.objid) if inst.respond_to?("#{target_field}=")
|
|
90
|
+
|
|
91
|
+
# Set custom attributes (validated against field schema)
|
|
92
|
+
safe_attrs = ThroughModelOperations.validated_attrs(through_class, attrs)
|
|
93
|
+
safe_attrs.each { |k, v| inst.send("#{k}=", v) }
|
|
94
|
+
|
|
95
|
+
# Timestamps
|
|
96
|
+
inst.created_at = Familia.now.to_f if inst.respond_to?(:created_at=)
|
|
97
|
+
inst.updated_at = Familia.now.to_f if inst.respond_to?(:updated_at=)
|
|
98
|
+
|
|
99
|
+
inst.save
|
|
100
|
+
inst
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Activate a staged through model, completing the relationship.
|
|
104
|
+
#
|
|
105
|
+
# This operation:
|
|
106
|
+
# 1. Validates the staged model belongs to the correct target
|
|
107
|
+
# 2. Validates the staged model hasn't already been activated
|
|
108
|
+
# 3. Creates a new composite-keyed through model via ThroughModelOperations
|
|
109
|
+
# 4. Destroys the UUID-keyed staged model
|
|
110
|
+
#
|
|
111
|
+
# The caller is responsible for the sorted set operations (ZADD active,
|
|
112
|
+
# SADD participations, ZREM staging) which happen in a transaction in
|
|
113
|
+
# the target method builder.
|
|
114
|
+
#
|
|
115
|
+
# @note Attribute handling: This method intentionally does NOT auto-merge
|
|
116
|
+
# attributes from the staged model. The application controls what data
|
|
117
|
+
# carries over by explicitly passing attrs. This design supports workflows
|
|
118
|
+
# where staged data (e.g., invitation metadata) differs from activated data
|
|
119
|
+
# (e.g., membership settings), and prevents accidental data leakage between
|
|
120
|
+
# lifecycle phases. To carry over staged attributes:
|
|
121
|
+
#
|
|
122
|
+
# activated = org.activate_members_instance(
|
|
123
|
+
# staged, customer,
|
|
124
|
+
# through_attrs: staged.to_h.slice(:role, :invited_by).merge(status: 'active')
|
|
125
|
+
# )
|
|
126
|
+
#
|
|
127
|
+
# @param through_class [Class] The through model class
|
|
128
|
+
# @param staged_model [Object] The staged through model to activate
|
|
129
|
+
# @param target [Object] The target instance
|
|
130
|
+
# @param participant [Object] The participant instance (now exists)
|
|
131
|
+
# @param attrs [Hash] Attributes for the activated through model (not merged with staged)
|
|
132
|
+
# @return [Object] The new composite-keyed through model
|
|
133
|
+
# @raise [ArgumentError] if staged model belongs to a different target
|
|
134
|
+
# @raise [ArgumentError] if staged model is already activated
|
|
135
|
+
# @raise [ArgumentError] if staged model does not exist (already destroyed)
|
|
136
|
+
#
|
|
137
|
+
def activate(through_class:, staged_model:, target:, participant:, attrs: {})
|
|
138
|
+
# Validate staged model still exists (may have been destroyed by previous activation or TTL)
|
|
139
|
+
unless staged_model.exists?
|
|
140
|
+
raise ArgumentError, 'Staged model does not exist (may have been already activated or expired)'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Validate staged model belongs to this target
|
|
144
|
+
target_field = "#{target.class.config_name}_objid"
|
|
145
|
+
if staged_model.respond_to?(target_field)
|
|
146
|
+
staged_target_objid = staged_model.send(target_field)
|
|
147
|
+
if staged_target_objid && staged_target_objid != target.objid
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"Staged model belongs to different target (expected #{target.objid}, got #{staged_target_objid})"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Validate staged model doesn't already have a participant (already activated)
|
|
154
|
+
participant_field = "#{participant.class.config_name}_objid"
|
|
155
|
+
if staged_model.respond_to?(participant_field)
|
|
156
|
+
existing_participant = staged_model.send(participant_field)
|
|
157
|
+
if existing_participant && !existing_participant.to_s.empty?
|
|
158
|
+
raise ArgumentError, 'Model already activated (participant_objid already set)'
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Create composite-keyed through model via existing machinery
|
|
163
|
+
activated_model = ThroughModelOperations.find_or_create(
|
|
164
|
+
through_class: through_class,
|
|
165
|
+
target: target,
|
|
166
|
+
participant: participant,
|
|
167
|
+
attrs: attrs,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Destroy the UUID-keyed staged model
|
|
171
|
+
staged_model.destroy!
|
|
172
|
+
|
|
173
|
+
activated_model
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Unstage a through model, removing it from the staging set.
|
|
177
|
+
#
|
|
178
|
+
# Used when an invitation is revoked before acceptance.
|
|
179
|
+
# The caller handles ZREM from staging set.
|
|
180
|
+
#
|
|
181
|
+
# @param staged_model [Object] The staged through model to remove
|
|
182
|
+
# @return [Boolean] true if destroyed, false if model didn't exist
|
|
183
|
+
#
|
|
184
|
+
def unstage(staged_model:)
|
|
185
|
+
return false unless staged_model.exists?
|
|
186
|
+
|
|
187
|
+
staged_model.destroy!
|
|
188
|
+
true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Clean up a stale staging set entry.
|
|
192
|
+
#
|
|
193
|
+
# Called when a staged model no longer exists (e.g., TTL expiration, manual deletion)
|
|
194
|
+
# but its objid is still in the staging set. This implements lazy cleanup similar
|
|
195
|
+
# to how Familia handles ghost entries in the `instances` sorted set.
|
|
196
|
+
#
|
|
197
|
+
# @param staging_collection [Familia::SortedSet] The staging collection
|
|
198
|
+
# @param staged_objid [String] The objid to clean up
|
|
199
|
+
# @return [Boolean] true if entry was removed, false if not found
|
|
200
|
+
#
|
|
201
|
+
def cleanup_stale_staged_entry(staging_collection:, staged_objid:)
|
|
202
|
+
removed = staging_collection.remove(staged_objid)
|
|
203
|
+
Familia.debug "[StagedOperations] Cleaned up stale staging entry: #{staged_objid}" if removed
|
|
204
|
+
removed
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Load a staged model with lazy cleanup.
|
|
208
|
+
#
|
|
209
|
+
# Attempts to load the through model by objid. If the model doesn't exist
|
|
210
|
+
# (ghost entry), cleans up the staging set entry and returns nil.
|
|
211
|
+
#
|
|
212
|
+
# @param through_class [Class] The through model class
|
|
213
|
+
# @param staged_objid [String] The objid to load
|
|
214
|
+
# @param staging_collection [Familia::SortedSet, nil] Optional staging collection for cleanup
|
|
215
|
+
# @return [Object, nil] The loaded model or nil if not found
|
|
216
|
+
#
|
|
217
|
+
def load_staged(through_class:, staged_objid:, staging_collection: nil)
|
|
218
|
+
model = through_class.load(staged_objid)
|
|
219
|
+
|
|
220
|
+
# Check if model actually exists (not just loaded with empty data)
|
|
221
|
+
if model.nil? || !model.exists?
|
|
222
|
+
# Clean up ghost entry if staging collection provided
|
|
223
|
+
if staging_collection
|
|
224
|
+
cleanup_stale_staged_entry(
|
|
225
|
+
staging_collection: staging_collection,
|
|
226
|
+
staged_objid: staged_objid,
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
return nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
model
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|