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.tags.add("admin")  # warning printed, SADD executes

# After (2.10.0): raises Familia::Problem
user = User.new(email: "alice@example.com")
user.tags.add("admin")  # raises — save the parent first

# Fix: save before mutating collections
user = User.new(email: "alice@example.com")
user.save
user.tags.add("admin")  # works

# Or use build to do it atomically
user = User.build(email: "alice@example.com") do |u|
  u.tags.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.tags.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.tags.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