Module: Familia::Features::Relationships::Participation::StagedOperations
- Defined in:
- lib/familia/features/relationships/participation/staged_operations.rb
Overview
StagedOperations provides lifecycle management for staged (deferred) relationships.
Staged relationships enable invitation workflows where a through model must exist before the participant does. The staging set holds through model objids until the relationship is activated.
Key characteristics:
- UUID identifier: Staged models use UUID keys (not composite keys)
- Deferred activation: Through model exists before participant
- Clean handoff: Activation creates composite-keyed model, destroys UUID-keyed model
- Lazy cleanup: Ghost entries (deleted models still in staging set) are cleaned on access
Lifecycle: stage → UUID-keyed through model + ZADD staging set activate → composite-keyed through model + ZADD active + SADD participations + cleanup unstage → ZREM staging + destroy through model
Example: class Membership < Familia::Horreum feature :object_identifier field :organization_objid field :customer_objid field :email field :role field :status end
class Customer < Familia::Horreum participates_in Organization, :members, through: Membership, staged: :pending_members end
# Stage (invitation sent) membership = org.stage_members_instance( through_attrs: { email: 'invite@example.com', role: 'viewer' } )
# Activate (invitation accepted) - explicitly carry over desired attributes # Note: attrs are NOT auto-merged from staged model (intentional design) activated = org.activate_members_instance( membership, customer, through_attrs: membership.to_h.slice(:role).merge(status: 'active') )
Class Method Summary collapse
-
.activate(through_class:, staged_model:, target:, participant:, attrs: {}) ⇒ Object
Activate a staged through model, completing the relationship.
-
.cleanup_stale_staged_entry(staging_collection:, staged_objid:) ⇒ Boolean
Clean up a stale staging set entry.
-
.load_staged(through_class:, staged_objid:, staging_collection: nil) ⇒ Object?
Load a staged model with lazy cleanup.
-
.stage(through_class:, target:, attrs: {}) ⇒ Object
Stage a new through model for deferred activation.
-
.unstage(staged_model:) ⇒ Boolean
Unstage a through model, removing it from the staging set.
Class Method Details
.activate(through_class:, staged_model:, target:, participant:, attrs: {}) ⇒ Object
Attribute handling: This method intentionally does NOT auto-merge attributes from the staged model. The application controls what data carries over by explicitly passing attrs. This design supports workflows where staged data (e.g., invitation metadata) differs from activated data (e.g., membership settings), and prevents accidental data leakage between lifecycle phases. To carry over staged attributes:
activated = org.activate_members_instance( staged, customer, through_attrs: staged.to_h.slice(:role, :invited_by).merge(status: 'active') )
Activate a staged through model, completing the relationship.
This operation:
- Validates the staged model belongs to the correct target
- Validates the staged model hasn't already been activated
- Creates a new composite-keyed through model via ThroughModelOperations
- Destroys the UUID-keyed staged model
The caller is responsible for the sorted set operations (ZADD active, SADD participations, ZREM staging) which happen in a transaction in the target method builder.
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/familia/features/relationships/participation/staged_operations.rb', line 137 def activate(through_class:, staged_model:, target:, participant:, attrs: {}) # Validate staged model still exists (may have been destroyed by previous activation or TTL) unless staged_model.exists? raise ArgumentError, 'Staged model does not exist (may have been already activated or expired)' end # Validate staged model belongs to this target target_field = "#{target.class.config_name}_objid" if staged_model.respond_to?(target_field) staged_target_objid = staged_model.send(target_field) if staged_target_objid && staged_target_objid != target.objid raise ArgumentError, "Staged model belongs to different target (expected #{target.objid}, got #{staged_target_objid})" end end # Validate staged model doesn't already have a participant (already activated) participant_field = "#{participant.class.config_name}_objid" if staged_model.respond_to?(participant_field) existing_participant = staged_model.send(participant_field) if existing_participant && !existing_participant.to_s.empty? raise ArgumentError, 'Model already activated (participant_objid already set)' end end # Create composite-keyed through model via existing machinery activated_model = ThroughModelOperations.find_or_create( through_class: through_class, target: target, participant: participant, attrs: attrs, ) # Destroy the UUID-keyed staged model staged_model.destroy! activated_model end |
.cleanup_stale_staged_entry(staging_collection:, staged_objid:) ⇒ Boolean
Clean up a stale staging set entry.
Called when a staged model no longer exists (e.g., TTL expiration, manual deletion)
but its objid is still in the staging set. This implements lazy cleanup similar
to how Familia handles ghost entries in the instances sorted set.
201 202 203 204 205 |
# File 'lib/familia/features/relationships/participation/staged_operations.rb', line 201 def cleanup_stale_staged_entry(staging_collection:, staged_objid:) removed = staging_collection.remove(staged_objid) Familia.debug "[StagedOperations] Cleaned up stale staging entry: #{staged_objid}" if removed removed end |
.load_staged(through_class:, staged_objid:, staging_collection: nil) ⇒ Object?
Load a staged model with lazy cleanup.
Attempts to load the through model by objid. If the model doesn't exist (ghost entry), cleans up the staging set entry and returns nil.
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'lib/familia/features/relationships/participation/staged_operations.rb', line 217 def load_staged(through_class:, staged_objid:, staging_collection: nil) model = through_class.load(staged_objid) # Check if model actually exists (not just loaded with empty data) if model.nil? || !model.exists? # Clean up ghost entry if staging collection provided if staging_collection cleanup_stale_staged_entry( staging_collection: staging_collection, staged_objid: staged_objid, ) end return nil end model end |
.stage(through_class:, target:, attrs: {}) ⇒ Object
Non-atomic operation: The through model is saved before being added to the
staging set (by the caller). If the staging set add fails (rare Redis failure),
an orphaned through model may exist. The lazy cleanup mechanism in load_staged
handles such orphans on subsequent access. This trade-off is acceptable for the
invitation use case where Redis failures are uncommon.
The staging set score (set by the caller) represents the creation timestamp.
Retrieve via staging_collection.score(objid) if needed.
Stage a new through model for deferred activation.
Creates a through model with a UUID key (not composite) and sets only the target foreign key. The participant foreign key remains nil until activation.
The UUID comes from the through model's feature :object_identifier init hook.
This is the key difference from ThroughModelOperations.find_or_create, which
uses build_key to create a composite key from target and participant.
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/familia/features/relationships/participation/staged_operations.rb', line 82 def stage(through_class:, target:, attrs: {}) # Create through model - UUID is auto-generated by object_identifier feature's init hook # No objid parameter passed = feature generates UUID (not composite key like find_or_create) inst = through_class.new # Set target foreign key only (participant is nil during staging) target_field = "#{target.class.config_name}_objid" inst.send("#{target_field}=", target.objid) if inst.respond_to?("#{target_field}=") # Set custom attributes (validated against field schema) safe_attrs = ThroughModelOperations.validated_attrs(through_class, attrs) safe_attrs.each { |k, v| inst.send("#{k}=", v) } # Timestamps inst.created_at = Familia.now.to_f if inst.respond_to?(:created_at=) inst.updated_at = Familia.now.to_f if inst.respond_to?(:updated_at=) inst.save inst end |
.unstage(staged_model:) ⇒ Boolean
Unstage a through model, removing it from the staging set.
Used when an invitation is revoked before acceptance. The caller handles ZREM from staging set.
184 185 186 187 188 189 |
# File 'lib/familia/features/relationships/participation/staged_operations.rb', line 184 def unstage(staged_model:) return false unless staged_model.exists? staged_model.destroy! true end |