Migrating to Familia 2.10

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.

2.10.1+ adds a project-wide sweep so you don't enumerate indexes by hand. It detects which class-level unique indexes still hold legacy data and rebuilds them, and provides a boot guard that fails before a stale index silently misses a find_by_* lookup:

Familia.stale_indexes.each(&:rebuild!)   # rebuild only what's stale
Familia.assert_indexes_current!          # boot guard: raises if any index is stale

See Relationships → Introspection.

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

each_record on participation collections

Collections created by participates_in and class_participates_in now carry a record_class: option pointing at the participant class. This lets each_record iterate the collection and load participant records via load_multi:

class Domain < Familia::Horreum
  feature :relationships
  participates_in Customer, :domains, score: :created_at
end

# Before 2.10.0: raised Familia::Problem
#   "each_record requires a DataType with a :record_class (or :class) option…"
# After: yields each loaded Domain
customer.domains.each_record { |domain| domain.refresh_dns! }

This replaces the manual workaround:

# No longer necessary
Domain.load_multi(customer.domains.to_a).compact.each { |d| ... }

No migration, and no behavior change. record_class: is a loading-only hint: it tells each_record which class to hydrate, but does not change how the collection serializes or deserializes. members, to_a, member?, and score behave exactly as before (including JSON type-preservation for numeric-looking identifiers and the issue #212 "use objects, not raw strings" lookup rule). The stored bytes are unchanged. The only difference is that each_record now works.

This is deliberately narrower than instances and unique_index, which use class: + reference: true because they also want raw-string read semantics. Participation only needs the loading capability, so it uses the record_class: option and leaves read behavior untouched. If you want each_record on a collection you declare by hand, add the same option: sorted_set :domains, record_class: Domain.

One edge to know about: if you pre-declare the backing collection yourself (e.g. sorted_set :domains on the target class) before participates_in runs, your declaration wins and the collection is not given record_class:. Declare it as sorted_set :domains, record_class: Domain if you want each_record on a hand-declared participation collection.

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.

Cross-model atomic_write

The module-level Familia.atomic_write(*instances, ...) persists several (possibly different-class) Horreum instances in a single MULTI/EXEC. Once the MULTI opens, every instance's dbclient resolves to the same transaction connection, so each model's HMSET/EXPIRE/index HSET/instances ZADD queue together and commit atomically:

Familia.atomic_write(customer, org) do
  org.owner_id = customer.identifier
  customer.orgs.add(org.identifier)
end
# customer's HMSET + org's HMSET + both collections' writes
# all land in ONE MULTI/EXEC

The optional block runs inside the transaction, before each instance is persisted — the place to wire cross-references between instances and mutate collections. Read-validate (prepare_for_save: timestamps + unique-index reads) runs outside the MULTI; only the writes are atomic. The method returns true on a clean commit and clears each instance's dirty state; on rollback it returns false (or propagates the raised exception) and leaves dirty state intact on every instance.

Constraint: all instances must share one logical database. MULTI/EXEC is valid only within a single logical database, so the transaction is anchored on instances.first.dbclient and a pre-flight guard rejects any set of roots (or their related fields) that span databases:

class Account < Familia::Horreum; logical_database 0; end
class AuditEntry < Familia::Horreum; logical_database 5; end

Familia.atomic_write(account, audit_entry) { ... }
# raises Familia::CrossDatabaseError before any write —
# MULTI/EXEC cannot cross logical databases

For configurations that genuinely span databases, persist each model separately (e.g. with save_with_collections).

Race-safe create-only across models

Combine watch_keys: with a pre_check: that rejects existing keys to get create-only semantics spanning multiple models:

Familia.atomic_write(customer, org,
  watch_keys: [customer.dbkey, org.dbkey],
  pre_check: -> {
    [customer, org].each { |r| raise Familia::RecordExistsError, r.dbkey if r.exists? }
  }
) do
  customer.name = 'Acme Owner'
  org.owner_id = customer.identifier
end

A concurrent creation of either key during the WATCH window aborts the whole MULTI and retries; on retry the existence check raises RecordExistsError — no silent overwrite. If the watched keys keep changing and retries are exhausted, Familia::OptimisticLockError is raised instead.

Redis Cluster note: a cross-model MULTI additionally requires the watched and written keys to share a hash slot, otherwise the server returns a CROSSSLOT error. Co-locate related models with hash tags so they route to one slot, e.g. customer:{acct42}:object and org:{acct42}:object.

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