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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 715c43e8b2b403b23fdb6ffbb33c8558a8dbccb6c9cc9dc80a5892a193cdf8c6
4
- data.tar.gz: 89a9fafdd21877562178d8b4b103f7fe119b6ee12d50ebec9397691bffbf3094
3
+ metadata.gz: 9dfc0a904fa0458350fcdc3098b01e4ecbbe12846b9ddcb2265929d1f05f3a3d
4
+ data.tar.gz: ecd74f3b069bde39291fa5c750ed7a4d6f38938f4514563fd87309380b711cba
5
5
  SHA512:
6
- metadata.gz: b05dcefb1c009d499f6f41918fe01e29dc7604468365f0b1b6de02ea61544b3948d2e4aa3d8909069b833d413e915a6e489af0228fa4976299b9d00f074a4e3c
7
- data.tar.gz: 0b90e00e99299f33f48a689a58dca2a941d3db7bf53c455990a8989768aca41f0837941db7ea71a0568b9f2598f4777acff5570d834d2e8ac188112cc6a67d15
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.3.3)
4
+ familia (2.4.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -622,7 +622,7 @@ Extend current TTL by additional time.
622
622
 
623
623
  **Returns:** Boolean indicating success
624
624
 
625
- #### `persist!`
625
+ #### `persist!` / `clear_expiration!`
626
626
  Remove TTL, making object persistent.
627
627
 
628
628
  **Returns:** Boolean indicating success
@@ -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