Housekeeping Feature Guide
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.
[!TIP] Enable with
feature :housekeepingand register cleanup blocks withchore :name do |obj| ... end. Run them withobj.tidy!. Iteration and persistence are the caller's responsibility.
Quick Start
class Organization < Familia::Horreum
feature :housekeeping
field :planid
chore :standardize_planid do |org|
canonical = case org.planid
when "pro", "Pro", "professional_v1" then "professional"
when "free", "Free", "basic" then "free"
end
if canonical && canonical != org.planid
org.planid = canonical
org.save
true
end
end
end
org = Organization.from_identifier("acme-corp")
org.tidy!
# => { standardize_planid: true }
When to Use
| Tool | Use When |
|---|---|
Familia::Migration::Base |
Versioned, one-shot transformation tracked across releases |
feature :housekeeping |
Short-lived chore run nightly until data is clean, then removed |
| Defensive code in setters | Permanent invariant enforced on every write |
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.
Core Capabilities
Registration -- Class-Level DSL
Each chore is a named block bound to the model class:
class User < Familia::Horreum
feature :housekeeping
field :email, :timezone
chore :downcase_email do |user|
next unless user.email && user.email != user.email.downcase
user.email = user.email.downcase
user.save
true
end
chore :default_timezone do |user|
next if user.timezone
user.timezone = "UTC"
user.save
true
end
end
User.chores.keys
# => [:downcase_email, :default_timezone]
Execution -- Single Instance
Run all registered chores, or one by name:
user = User.from_identifier("alice@example.com")
user.tidy!
# => { downcase_email: true, default_timezone: nil }
user.tidy!(:downcase_email)
# => { downcase_email: true }
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.
Iteration -- Caller's Responsibility
The feature operates on a single instance. Bulk runs live in the consumer app:
# nightly rake task
namespace :data do
task tidy_orgs: :environment do
stats = Hash.new(0)
Organization.instances.each do |id|
org = Organization.find_by_id(id) or next
results = org.tidy!
results.each { |name, result| stats[name] += 1 if result }
end
puts stats.inspect
end
end
The feature has no opinion about batching, SCAN vs KEYS, error aggregation, or scheduling -- the consumer app owns all of that.
Generated Method Reference
When a class declares feature :housekeeping
| Class | Method | Purpose |
|---|---|---|
| Class | chore(name, &block) |
Register a chore |
chores |
Hash of registered chores | |
| Instance | tidy!(name = nil) |
Run all (or one) chore; returns Hash |
Design Constraints
- No implicit saves. The block must call
save(orcommit_fields) itself. The feature does not auto-persist. - No iteration. Operates on a single instance. There is no class-level
tidy_all!. - No ordering. Chores run in registration order, but should not depend on each other. If order matters, write one chore with sequential steps.
- Idempotent by convention. Use the conditional pattern (
if canonical && canonical != org.planid) so a second run is a no-op. - Errors propagate. The block can raise; the iteration code in the consumer app decides whether to rescue.
Common Patterns
Multiple Independent Chores
class Customer < Familia::Horreum
feature :housekeeping
chore :trim_whitespace do |c|
next unless c.name && c.name != c.name.strip
c.name = c.name.strip
c.save
true
end
chore :uppercase_country do |c|
next unless c.country && c.country != c.country.upcase
c.country = c.country.upcase
c.save
true
end
end
customer.tidy!
# => { trim_whitespace: true, uppercase_country: nil }
Sequential Steps in One Chore
When step B depends on step A's result, keep them in one block:
chore :reconcile_billing do |account|
changed = false
if account.plan_id == "legacy"
account.plan_id = "standard"
changed = true
end
if account.plan_id == "standard" && account.billing_cycle.nil?
account.billing_cycle = "monthly"
changed = true
end
if changed
account.save
true
end
end
Tracking Modified Records
modified = []
Organization.instances.each do |id|
org = Organization.find_by_id(id) or next
results = org.tidy!
modified << id if results.values.any?
end
puts "Modified #{modified.size} records: #{modified.inspect}"
Error Aggregation
errors = {}
Organization.instances.each do |id|
org = Organization.find_by_id(id) or next
begin
org.tidy!
rescue => e
errors[id] = e.
end
end
Best Practices
- Keep chores short-lived. Delete the registration once data is clean.
- Use
||=and conditional checks so a second run is a no-op. - Save inside the block -- the feature does not persist for you.
- Return truthy on modification, nil on no-op so callers can collect stats.
- Prefer migrations for one-shot, versioned transformations. Use housekeeping for ongoing tidying that can be run repeatedly.
See Also
- Writing Migrations - Versioned, one-shot data transformations
- Field System - How field values are stored and serialized
- Feature System - How features are mixed into Horreum classes