familia 2.6.0 → 2.7.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: 5577963e36f6c5ce2ce1fc977458c092b6883430e1c6c3e4c72742ddb4cae795
4
- data.tar.gz: bc958eef278b236f894652b4954e7235a917c858f26e8c63427b1fb7bb62713a
3
+ metadata.gz: 7b0138946cb9d48258b9f43b8a8a81aa895bfded213543feee354b3d383dd98a
4
+ data.tar.gz: bd5b6ee397c2ef19cef85fe7638ead34b5ed40a81c8362f539ed757d0d713ca2
5
5
  SHA512:
6
- metadata.gz: 2820cc6134dd2a707a8e7c91855c42b622807748cc428984bb725f71faada7489ed5211ac3dedbe887132e13523cd4e8c565abeaf57c7d9df411566fb60766ed
7
- data.tar.gz: 1bc50c330c90ac7edcb460c7f8688d8e47d8329e2770a36b8963e24ea00cb258ee017f2c7ec905a2f19b38949ea609f381a9d65d0460711cc725c26b9f664053
6
+ metadata.gz: 8108ee045d1f4f1bdc722a1b56a6518a32edebd18ae028dfdcd793a62044cc8bea509e06d8399179c11b1eeef971dd980d1bcd18654cd3a375af99b641c514a6
7
+ data.tar.gz: 395bcb27503bb30734d0d6e16fa8e95b33fa072dfaca56c2b9b06d33859c4cb915f766923a492cbdaae5407e45db2c76ee074a6c6d7aac245b822332f44d082c
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,40 @@ 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.7.0:
11
+
12
+ 2.7.0 — 2026-05-13
13
+ ==================
14
+
15
+ Added
16
+ -----
17
+
18
+ - New ``housekeeping`` feature for ``Familia::Horreum``: a declarative DSL
19
+ (``chore :name do |obj| ... end``) for registering named cleanup blocks on
20
+ a model class, plus an instance method ``tidy!`` that runs all (or one)
21
+ registered chore against a single object. The feature owns registration
22
+ and per-instance execution only -- iteration, batching, scheduling and
23
+ error aggregation are the consumer application's responsibility, keeping
24
+ it distinct from ``Familia::Migration`` (which is for versioned, one-shot
25
+ transformations). Resolves #258.
26
+
27
+ Documentation
28
+ -------------
29
+
30
+ - Added ``docs/guides/feature-housekeeping.md`` covering the API, the
31
+ ``housekeeping`` vs ``migration`` vs defensive-setter trade-off,
32
+ generated method reference, design constraints, and common patterns
33
+ (multiple chores, sequential steps in one chore, tracking modified
34
+ records, error aggregation).
35
+
36
+ AI Assistance
37
+ -------------
38
+
39
+ - Drafted the housekeeping feature module, the tryouts test suite, and the
40
+ guide using Claude Code, working from the API proposal in issue #258 and
41
+ the existing ``feature-relationships.md`` and ``safe_dump.rb`` as style
42
+ templates.
43
+
10
44
  .. _changelog-2.6.0:
11
45
 
12
46
  2.6.0 — 2026-04-17
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (2.6.0)
4
+ familia (2.7.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  connection_pool (>= 2.4, < 4.0)
7
7
  csv (~> 3.3)
@@ -0,0 +1,46 @@
1
+ Added
2
+ ~~~~~
3
+
4
+ - Instance-scoped ``audit_multi_indexes`` is now fully implemented.
5
+ Discovers per-scope bucket keys via SCAN, partitions them by scope
6
+ instance, and reports stale members, orphaned buckets, and missing
7
+ entries in the same shape as the class-level audit. Orphan entries
8
+ carry a ``:reason`` (``:scope_missing`` or ``:field_value_unheld``)
9
+ and a ``:scope_id``. Missing entries are detected via the indexed
10
+ class's ``participates_in`` relationship to the scope class; when
11
+ absent, the result carries ``missing_status: :not_audited``.
12
+ Resolves the ``:not_implemented`` follow-up from #217.
13
+
14
+ - ``repair_multi_indexes!`` class method that invokes the existing
15
+ ``rebuild_<index_name>`` methods for both class-level (one call on
16
+ the indexed class) and instance-scoped (one call per scope
17
+ instance) multi-indexes. Indexes whose audit status is ``:ok`` are
18
+ skipped; rebuild methods that don't exist or scope classes
19
+ without an ``instances`` collection are recorded in ``:skipped``
20
+ with a reason.
21
+
22
+ Changed
23
+ ~~~~~~~
24
+
25
+ - ``repair_all!`` now runs each repair stage inside its own rescue
26
+ boundary; a failure in one dimension no longer prevents the others
27
+ from running. The return hash gains ``:status`` (``:ok`` or
28
+ ``:partial_failure``), ``:errors`` (per-stage exception details
29
+ when raised), and ``:multi_indexes`` (results from the new
30
+ ``repair_multi_indexes!``). An opt-in ``verify: true`` kwarg
31
+ re-runs ``health_check`` after repair and exposes the result as
32
+ ``:post_audit`` / ``:verified`` so callers can confirm the run
33
+ actually drove the model back to a healthy state.
34
+
35
+ - ``AuditReport#complete?`` is no longer false-positive due to
36
+ ``:not_implemented`` stubs in ``multi_indexes`` -- instance-scoped
37
+ indexes return ``:ok`` or ``:issues_found`` like class-level ones.
38
+
39
+ AI Assistance
40
+ ~~~~~~~~~~~~~
41
+
42
+ - Instance-scoped multi-index audit algorithm (bucket discovery,
43
+ scope existence batching, participation-driven missing detection),
44
+ ``repair_multi_indexes!``, the ``repair_all!`` robustness
45
+ refactor, and the accompanying tryouts coverage were authored
46
+ with Claude Code assistance against the #217 review branch.
@@ -0,0 +1,217 @@
1
+ # Housekeeping Feature Guide
2
+
3
+ The Housekeeping feature provides a declarative DSL for registering named cleanup chores on Horreum models. It is designed for short-lived, repeated tidying against fields whose values have drifted over time -- not for versioned, one-shot migrations.
4
+
5
+ > [!TIP]
6
+ > Enable with `feature :housekeeping` and register cleanup blocks with `chore :name do |obj| ... end`. Run them with `obj.tidy!`. Iteration and persistence are the caller's responsibility.
7
+
8
+ ## Quick Start
9
+
10
+ ```ruby
11
+ class Organization < Familia::Horreum
12
+ feature :housekeeping
13
+
14
+ field :planid
15
+
16
+ chore :standardize_planid do |org|
17
+ canonical = case org.planid
18
+ when "pro", "Pro", "professional_v1" then "professional"
19
+ when "free", "Free", "basic" then "free"
20
+ end
21
+ if canonical && canonical != org.planid
22
+ org.planid = canonical
23
+ org.save
24
+ true
25
+ end
26
+ end
27
+ end
28
+
29
+ org = Organization.from_identifier("acme-corp")
30
+ org.tidy!
31
+ # => { standardize_planid: true }
32
+ ```
33
+
34
+ ## When to Use
35
+
36
+ | Tool | Use When |
37
+ |------|----------|
38
+ | `Familia::Migration::Base` | Versioned, one-shot transformation tracked across releases |
39
+ | `feature :housekeeping` | Short-lived chore run nightly until data is clean, then removed |
40
+ | Defensive code in setters | Permanent invariant enforced on every write |
41
+
42
+ Housekeeping fills the gap between migrations (heavy, tracked) and inline coercion (permanent). Register a chore, run it on a schedule for a few days, verify clean data, then delete the chore and the defensive code that handled the messy values.
43
+
44
+ ## Core Capabilities
45
+
46
+ ### Registration -- Class-Level DSL
47
+
48
+ Each chore is a named block bound to the model class:
49
+
50
+ ```ruby
51
+ class User < Familia::Horreum
52
+ feature :housekeeping
53
+
54
+ field :email, :timezone
55
+
56
+ chore :downcase_email do |user|
57
+ next unless user.email && user.email != user.email.downcase
58
+ user.email = user.email.downcase
59
+ user.save
60
+ true
61
+ end
62
+
63
+ chore :default_timezone do |user|
64
+ next if user.timezone
65
+ user.timezone = "UTC"
66
+ user.save
67
+ true
68
+ end
69
+ end
70
+
71
+ User.chores.keys
72
+ # => [:downcase_email, :default_timezone]
73
+ ```
74
+
75
+ ### Execution -- Single Instance
76
+
77
+ Run all registered chores, or one by name:
78
+
79
+ ```ruby
80
+ user = User.from_identifier("alice@example.com")
81
+
82
+ user.tidy!
83
+ # => { downcase_email: true, default_timezone: nil }
84
+
85
+ user.tidy!(:downcase_email)
86
+ # => { downcase_email: true }
87
+ ```
88
+
89
+ The return value is a hash mapping chore name to the block's return value. A truthy result signals "modified"; `nil` or `false` signals "no-op". The feature does not interpret these values -- they are passed through for the caller's stats collection.
90
+
91
+ ### Iteration -- Caller's Responsibility
92
+
93
+ The feature operates on a single instance. Bulk runs live in the consumer app:
94
+
95
+ ```ruby
96
+ # nightly rake task
97
+ namespace :data do
98
+ task tidy_orgs: :environment do
99
+ stats = Hash.new(0)
100
+ Organization.instances.each do |id|
101
+ org = Organization.find_by_id(id) or next
102
+ results = org.tidy!
103
+ results.each { |name, result| stats[name] += 1 if result }
104
+ end
105
+ puts stats.inspect
106
+ end
107
+ end
108
+ ```
109
+
110
+ The feature has no opinion about batching, SCAN vs KEYS, error aggregation, or scheduling -- the consumer app owns all of that.
111
+
112
+ ## Generated Method Reference
113
+
114
+ ### When a class declares `feature :housekeeping`
115
+
116
+ | Class | Method | Purpose |
117
+ |-------|--------|---------|
118
+ | **Class** | `chore(name, &block)` | Register a chore |
119
+ | | `chores` | Hash of registered chores |
120
+ | **Instance** | `tidy!(name = nil)` | Run all (or one) chore; returns Hash |
121
+
122
+ ## Design Constraints
123
+
124
+ 1. **No implicit saves.** The block must call `save` (or `commit_fields`) itself. The feature does not auto-persist.
125
+ 2. **No iteration.** Operates on a single instance. There is no class-level `tidy_all!`.
126
+ 3. **No ordering.** Chores run in registration order, but should not depend on each other. If order matters, write one chore with sequential steps.
127
+ 4. **Idempotent by convention.** Use the conditional pattern (`if canonical && canonical != org.planid`) so a second run is a no-op.
128
+ 5. **Errors propagate.** The block can raise; the iteration code in the consumer app decides whether to rescue.
129
+
130
+ ## Common Patterns
131
+
132
+ ### Multiple Independent Chores
133
+
134
+ ```ruby
135
+ class Customer < Familia::Horreum
136
+ feature :housekeeping
137
+
138
+ chore :trim_whitespace do |c|
139
+ next unless c.name && c.name != c.name.strip
140
+ c.name = c.name.strip
141
+ c.save
142
+ true
143
+ end
144
+
145
+ chore :uppercase_country do |c|
146
+ next unless c.country && c.country != c.country.upcase
147
+ c.country = c.country.upcase
148
+ c.save
149
+ true
150
+ end
151
+ end
152
+
153
+ customer.tidy!
154
+ # => { trim_whitespace: true, uppercase_country: nil }
155
+ ```
156
+
157
+ ### Sequential Steps in One Chore
158
+
159
+ When step B depends on step A's result, keep them in one block:
160
+
161
+ ```ruby
162
+ chore :reconcile_billing do |account|
163
+ changed = false
164
+ if account.plan_id == "legacy"
165
+ account.plan_id = "standard"
166
+ changed = true
167
+ end
168
+ if account.plan_id == "standard" && account.billing_cycle.nil?
169
+ account.billing_cycle = "monthly"
170
+ changed = true
171
+ end
172
+ if changed
173
+ account.save
174
+ true
175
+ end
176
+ end
177
+ ```
178
+
179
+ ### Tracking Modified Records
180
+
181
+ ```ruby
182
+ modified = []
183
+ Organization.instances.each do |id|
184
+ org = Organization.find_by_id(id) or next
185
+ results = org.tidy!
186
+ modified << id if results.values.any?
187
+ end
188
+ puts "Modified #{modified.size} records: #{modified.inspect}"
189
+ ```
190
+
191
+ ### Error Aggregation
192
+
193
+ ```ruby
194
+ errors = {}
195
+ Organization.instances.each do |id|
196
+ org = Organization.find_by_id(id) or next
197
+ begin
198
+ org.tidy!
199
+ rescue => e
200
+ errors[id] = e.message
201
+ end
202
+ end
203
+ ```
204
+
205
+ ## Best Practices
206
+
207
+ 1. **Keep chores short-lived.** Delete the registration once data is clean.
208
+ 2. **Use `||=` and conditional checks** so a second run is a no-op.
209
+ 3. **Save inside the block** -- the feature does not persist for you.
210
+ 4. **Return truthy on modification, nil on no-op** so callers can collect stats.
211
+ 5. **Prefer migrations for one-shot, versioned transformations.** Use housekeeping for ongoing tidying that can be run repeatedly.
212
+
213
+ ## See Also
214
+
215
+ - [**Writing Migrations**](writing-migrations.md) - Versioned, one-shot data transformations
216
+ - [**Field System**](field-system.md) - How field values are stored and serialized
217
+ - [**Feature System**](feature-system.md) - How features are mixed into Horreum classes
data/docs/guides/index.md CHANGED
@@ -37,9 +37,13 @@ Welcome to the comprehensive documentation for Familia v2.0. This guide collecti
37
37
  13. **[Quantization](feature-quantization.md)** - Time-based data bucketing for analytics
38
38
  14. **[Time Literals](time-literals.md)** - Time manipulation and formatting utilities
39
39
 
40
+ ### 🧹 Data Maintenance
41
+
42
+ 15. **[Housekeeping](feature-housekeeping.md)** - Declarative cleanup chores for drifted field values
43
+
40
44
  ### 🛠️ Implementation & Usage
41
45
 
42
- 15. **[Optimized Loading](optimized-loading.md)** - Reduce Redis commands by 50-96% for bulk object loading _(new!)_
46
+ 16. **[Optimized Loading](optimized-loading.md)** - Reduce Redis commands by 50-96% for bulk object loading _(new!)_
43
47
 
44
48
 
45
49
  ## 🚀 Quick Start Examples
@@ -0,0 +1,101 @@
1
+ # lib/familia/features/housekeeping.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Familia
6
+ module Features
7
+ # Housekeeping registers named cleanup chores on a Horreum class and runs
8
+ # them against a single instance. It is intended for short-lived, repeated
9
+ # tidying of fields whose values have drifted (e.g. running nightly for a
10
+ # few days, then removing the chore once data is clean).
11
+ #
12
+ # The feature owns registration and per-instance execution only. Iteration,
13
+ # batching, scheduling, error aggregation, and persistence are the caller's
14
+ # responsibility.
15
+ #
16
+ # Example:
17
+ #
18
+ # class Organization < Familia::Horreum
19
+ # feature :housekeeping
20
+ # field :planid
21
+ #
22
+ # chore :standardize_planid do |org|
23
+ # canonical = case org.planid
24
+ # when 'pro', 'Pro', 'professional_v1' then 'professional'
25
+ # when 'free', 'Free', 'basic' then 'free'
26
+ # end
27
+ # if canonical && canonical != org.planid
28
+ # org.planid = canonical
29
+ # org.save
30
+ # true
31
+ # end
32
+ # end
33
+ # end
34
+ #
35
+ # org = Organization.from_identifier('acme-corp')
36
+ # org.tidy!
37
+ # # => { standardize_planid: true }
38
+ #
39
+ # org.tidy!(:standardize_planid)
40
+ # # => { standardize_planid: true }
41
+ #
42
+ # See docs/guides/feature-housekeeping.md for the full guide.
43
+ module Housekeeping
44
+ Familia::Base.add_feature self, :housekeeping
45
+
46
+ def self.included(base)
47
+ Familia.trace :LOADED, self, base if Familia.debug?
48
+ base.extend ModelClassMethods
49
+ end
50
+
51
+ # Housekeeping::ModelClassMethods
52
+ module ModelClassMethods
53
+ # Register a chore by name. The block receives the instance.
54
+ #
55
+ # @param name [Symbol, String] chore identifier
56
+ # @yield [obj] block invoked with the instance during tidy!
57
+ # @return [Proc] the registered block
58
+ # @raise [ArgumentError] if name is blank or no block is given
59
+ def chore(name, &block)
60
+ raise ArgumentError, 'chore name required' if name.nil? || name.to_s.empty?
61
+ raise ArgumentError, "chore #{name.inspect} requires a block" unless block
62
+
63
+ chores[name.to_sym] = block
64
+ end
65
+
66
+ # Registered chores in registration order. Subclasses inherit a copy
67
+ # of their parent's chores on first access, so registering a new chore
68
+ # on a subclass does not mutate the parent.
69
+ #
70
+ # @return [Hash{Symbol => Proc}]
71
+ def chores
72
+ @chores ||= if superclass.respond_to?(:chores)
73
+ superclass.chores.dup
74
+ else
75
+ {}
76
+ end
77
+ end
78
+ end
79
+
80
+ # Run all registered chores, or one chore by name.
81
+ #
82
+ # @param name [Symbol, String, nil] chore to run; nil runs all
83
+ # @return [Hash{Symbol => Object}] chore name => block return value
84
+ # @raise [ArgumentError] if name is given but not registered
85
+ def tidy!(name = nil)
86
+ registered = self.class.chores
87
+
88
+ if name
89
+ key = name.to_sym
90
+ raise ArgumentError, "unknown chore #{name.inspect}" unless registered.key?(key)
91
+
92
+ { key => registered[key].call(self) }
93
+ else
94
+ registered.each_with_object({}) do |(chore_name, block), results|
95
+ results[chore_name] = block.call(self)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end