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

Class Method Details

.activate(through_class:, staged_model:, target:, participant:, attrs: {}) ⇒ Object

Note:

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:

  1. Validates the staged model belongs to the correct target
  2. Validates the staged model hasn't already been activated
  3. Creates a new composite-keyed through model via ThroughModelOperations
  4. 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.

Parameters:

  • through_class (Class)

    The through model class

  • staged_model (Object)

    The staged through model to activate

  • target (Object)

    The target instance

  • participant (Object)

    The participant instance (now exists)

  • attrs (Hash) (defaults to: {})

    Attributes for the activated through model (not merged with staged)

Returns:

  • (Object)

    The new composite-keyed through model

Raises:

  • (ArgumentError)

    if staged model belongs to a different target

  • (ArgumentError)

    if staged model is already activated

  • (ArgumentError)

    if staged model does not exist (already destroyed)



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.

Parameters:

  • staging_collection (Familia::SortedSet)

    The staging collection

  • staged_objid (String)

    The objid to clean up

Returns:

  • (Boolean)

    true if entry was removed, false if not found



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.

Parameters:

  • through_class (Class)

    The through model class

  • staged_objid (String)

    The objid to load

  • staging_collection (Familia::SortedSet, nil) (defaults to: nil)

    Optional staging collection for cleanup

Returns:

  • (Object, nil)

    The loaded model or nil if not found



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

Note:

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.

Note:

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.

Parameters:

  • through_class (Class)

    The through model class (must have feature :object_identifier)

  • target (Object)

    The target instance (e.g., organization)

  • attrs (Hash) (defaults to: {})

    Attributes to set on the through model

Returns:

  • (Object)

    The created through model instance with UUID objid



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.

Parameters:

  • staged_model (Object)

    The staged through model to remove

Returns:

  • (Boolean)

    true if destroyed, false if model didn't exist



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