Migrating to Familia 2.10.0
This version introduces the build factory block, atomic WATCH support, per-class dirty-write warning controls, and encryption envelope versioning. Most changes are additive; the breaking changes below affect dirty-write behavior and unique-index storage format.
Breaking Changes
Collection writes on unsaved parents now raise by default
Mutating a collection (list/set/zset/hashkey) while its parent Horreum has never been saved now raises Familia::Problem instead of emitting a warning. This prevents orphaned collection data when the parent is never saved.
# Before (2.9.x): warns only
user = User.new(email: "alice@example.com")
user..add("admin") # warning printed, SADD executes
# After (2.10.0): raises Familia::Problem
user = User.new(email: "alice@example.com")
user..add("admin") # raises — save the parent first
# Fix: save before mutating collections
user = User.new(email: "alice@example.com")
user.save
user..add("admin") # works
# Or use build to do it atomically
user = User.build(email: "alice@example.com") do |u|
u..add("admin") # folded into the same MULTI/EXEC as the save
end
To restore the old warn-only behavior:
Familia.configure do |config|
config.raise_on_unsaved_parent_write = false
end
Dirty-write warnings deduplicated by default
Dirty-write warnings now fire once per distinct set of unsaved fields (:once mode) instead of on every collection write. Creating one object with several collections previously emitted 7-10 identical warnings; it now emits one.
To restore the previous every-write behavior:
# Per class
class User < Familia::Horreum
dirty_write_warnings :warn
end
# Or globally
Familia.dirty_write_warnings = :warn
Unique-index storage format
unique_index hashkeys now store identifiers as raw strings instead of
JSON-encoded strings. Public reads are unchanged — find_by_*, get,
hgetall, and values all return the same identifier. Only the byte format
stored in Redis differs:
# Before: redis-cli HGET user:email_lookup alice@example.com
"\"u1\""
# After:
"u1"
unique_index declarations now back their lookup with a reference-type hashkey
(class: <indexed class>, reference: true), the same shape used by instances.
This lets each_record iterate the index and load records via load_multi:
# Before 2.10.0: raised Familia::Problem
# After: yields each indexed User
User.email_lookup.each_record { |user| user.notify! }
Backward compatibility: legacy JSON-encoded values are detected and stripped
automatically at read time, so lookups and each_record work without a rebuild.
A deprecation warning is emitted for each legacy value encountered, pointing to
this guide. The rebuild eliminates the warnings and the per-read detection
overhead.
Recommended: rebuild existing unique indexes once after upgrading:
# Class-level unique_index
User.rebuild_email_lookup
# Instance-scoped unique_index (within: Company) — rebuild per scope instance
company.rebuild_badge_index
Rebuilds read from the source of truth and rewrite the index via an atomic swap (no downtime). Alternatively, re-save the affected records if you rely only on auto-indexing.
Encrypted-field AAD with transient fields
If you use aad_fields that reference a transient_field, the AAD output changes. Values encrypted by an earlier release were bound to "[REDACTED]" (the to_s of a RedactedString) and will no longer decrypt. Re-encrypt affected values after upgrading. This only affects transient fields used in aad_fields; regular fields are unchanged.
New Features
Horreum.build factory block
Creates an instance and commits scalars + collection mutations in a single MULTI/EXEC:
user = User.build(email: "alice@example.com") do |u|
u.name = "Alice"
u..add("admin")
u.sessions.push("abc123")
end
# HMSET + SADD + RPUSH all fire in one MULTI/EXEC
build has create-only semantics — it raises RecordExistsError if the identifier already exists. Without a block, it degenerates to new(...).save.
atomic_write with WATCH support
atomic_write now supports optimistic locking via watch_keys: and pre_check::
user.atomic_write(watch_keys: [user.dbkey], pre_check: -> { user.exists? }) do
user.name = "Alice"
user..add("vip")
end
The pre_check callable runs between WATCH and MULTI. On WATCH abort, the method retries with exponential backoff.
Per-class dirty-write warning controls
class SeedData < Familia::Horreum
dirty_write_warnings :off # suppress for known-safe bulk imports
end
class StrictModel < Familia::Horreum
dirty_write_warnings :strict # raise on any dirty collection write
end
Available modes: :strict, :warn, :once (default), :off.
Encryption key_material: option
Bind ciphertext to additional entropy beyond AAD:
class Secret < Familia::Horreum
encrypted_field :token, key_material: ->(rec) { rec.passphrase }
end
Wrong key_material derives a different key entirely (garbage output), unlike AAD which fails with an auth mismatch.
DatabaseLogger capture toggle
Disable in-memory command capture for production:
Familia::DatabaseLogger.capture_enabled = false
# Sampled log output continues; per-command buffer/timing cost drops to zero