familia 2.13.0 → 2.14.0.pre1

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.
Files changed (206) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +5 -0
  3. data/.github/workflows/ci.yml +86 -0
  4. data/.github/workflows/claude-code-review.yml +1 -1
  5. data/.github/workflows/claude.yml +1 -1
  6. data/.rubocop.yml +3 -0
  7. data/AGENTS.md +2 -1
  8. data/CHANGELOG.rst +216 -0
  9. data/Gemfile +8 -3
  10. data/Gemfile.lock +23 -23
  11. data/README.md +3 -0
  12. data/changelog.d/README.md +6 -4
  13. data/changelog.d/fragments/.gitkeep +0 -0
  14. data/changelog.d/scriv.ini +1 -0
  15. data/docs/guides/datatype-collections.md +3 -1
  16. data/docs/guides/encryption.md +3 -2
  17. data/docs/guides/feature-encrypted-fields.md +8 -6
  18. data/docs/guides/feature-relationships-indexing.md +189 -3
  19. data/docs/guides/feature-relationships-participation.md +6 -0
  20. data/docs/guides/feature-relationships.md +6 -0
  21. data/docs/guides/getting-started.md +8 -3
  22. data/docs/guides/index.md +18 -1
  23. data/docs/guides/optimized-loading.md +1 -1
  24. data/docs/guides/schema-validation.md +14 -6
  25. data/docs/migrating/dbkey-encoding.md +233 -0
  26. data/docs/migrating/key-patterns-and-index-rebuilds.md +303 -0
  27. data/docs/migrating/refresh.md +187 -0
  28. data/docs/migrating/runtime-dependencies.md +175 -0
  29. data/docs/migrating/transaction-replies.md +350 -0
  30. data/docs/overview.md +39 -25
  31. data/docs/reference/api-technical.md +235 -21
  32. data/docs/reference/migrations-specification.md +1 -0
  33. data/docs/reference/transaction_safety.md +175 -19
  34. data/examples/encrypted_fields.rb +9 -8
  35. data/examples/json_usage_patterns.rb +3 -2
  36. data/familia.gemspec +3 -4
  37. data/lib/familia/atomic_operations.rb +16 -5
  38. data/lib/familia/connection/behavior.rb +22 -0
  39. data/lib/familia/connection/handlers.rb +39 -0
  40. data/lib/familia/connection/operations.rb +13 -3
  41. data/lib/familia/connection/scope.rb +299 -0
  42. data/lib/familia/connection/transaction_core.rb +12 -3
  43. data/lib/familia/connection.rb +113 -0
  44. data/lib/familia/data_type/collection_base.rb +1 -1
  45. data/lib/familia/data_type/connection.rb +5 -0
  46. data/lib/familia/data_type/serialization.rb +35 -12
  47. data/lib/familia/data_type/types/counter.rb +21 -2
  48. data/lib/familia/data_type/types/hashkey.rb +153 -60
  49. data/lib/familia/data_type/types/json_stringkey.rb +75 -26
  50. data/lib/familia/data_type/types/listkey.rb +75 -16
  51. data/lib/familia/data_type/types/lock.rb +54 -2
  52. data/lib/familia/data_type/types/sorted_set.rb +193 -97
  53. data/lib/familia/data_type/types/stringkey.rb +30 -5
  54. data/lib/familia/data_type/types/unsorted_set.rb +54 -18
  55. data/lib/familia/data_type.rb +118 -6
  56. data/lib/familia/encryption/encrypted_data.rb +6 -6
  57. data/lib/familia/encryption/manager.rb +6 -6
  58. data/lib/familia/encryption/strict_base64.rb +41 -0
  59. data/lib/familia/encryption.rb +10 -8
  60. data/lib/familia/errors.rb +108 -9
  61. data/lib/familia/features/expiration/extensions.rb +7 -1
  62. data/lib/familia/features/expiration.rb +66 -24
  63. data/lib/familia/features/external_identifier.rb +7 -0
  64. data/lib/familia/features/housekeeping/enforce_collection_caps.rb +4 -0
  65. data/lib/familia/features/housekeeping.rb +4 -0
  66. data/lib/familia/features/object_identifier.rb +7 -0
  67. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +442 -65
  68. data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +7 -5
  69. data/lib/familia/features/relationships/indexing/record_key_ownership.rb +501 -0
  70. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +76 -6
  71. data/lib/familia/features/relationships/indexing.rb +71 -13
  72. data/lib/familia/features/relationships/participation/participant_methods.rb +22 -6
  73. data/lib/familia/features/relationships/participation/staged_operations.rb +10 -0
  74. data/lib/familia/features/relationships/participation/target_methods.rb +33 -8
  75. data/lib/familia/features/relationships/participation/through_model_operations.rb +23 -0
  76. data/lib/familia/features/relationships/participation.rb +41 -10
  77. data/lib/familia/features/relationships.rb +14 -0
  78. data/lib/familia/features/schema_validation.rb +8 -0
  79. data/lib/familia/field_type.rb +2 -2
  80. data/lib/familia/horreum/atomic_write.rb +6 -3
  81. data/lib/familia/horreum/connection.rb +4 -1
  82. data/lib/familia/horreum/database_commands.rb +28 -7
  83. data/lib/familia/horreum/definition.rb +11 -7
  84. data/lib/familia/horreum/management/audit.rb +443 -73
  85. data/lib/familia/horreum/management/audit_report.rb +22 -4
  86. data/lib/familia/horreum/management/repair.rb +66 -13
  87. data/lib/familia/horreum/management.rb +232 -67
  88. data/lib/familia/horreum/persistence.rb +332 -54
  89. data/lib/familia/horreum/related_fields.rb +51 -32
  90. data/lib/familia/horreum/serialization.rb +78 -18
  91. data/lib/familia/horreum.rb +98 -14
  92. data/lib/familia/index_descriptor.rb +28 -2
  93. data/lib/familia/logging.rb +8 -4
  94. data/lib/familia/migration/base.rb +22 -4
  95. data/lib/familia/migration/model.rb +77 -5
  96. data/lib/familia/migration/pipeline.rb +18 -1
  97. data/lib/familia/migration/rake_tasks.rb +4 -1
  98. data/lib/familia/migration/registry.rb +135 -43
  99. data/lib/familia/migration/runner.rb +26 -1
  100. data/lib/familia/schema_registry.rb +22 -1
  101. data/lib/familia/utils.rb +193 -1
  102. data/lib/familia/version.rb +1 -1
  103. data/lib/familia.rb +0 -1
  104. data/lib/middleware/database_logger.rb +29 -0
  105. data/try/bug_fixes/binary_dbkey_log_messages_try.rb +194 -0
  106. data/try/bug_fixes/binary_dbkey_update_expiration_try.rb +135 -0
  107. data/try/bug_fixes/binary_dbkey_watch_abort_message_try.rb +66 -0
  108. data/try/bug_fixes/binary_identifier_non_ascii_prefix_try.rb +152 -0
  109. data/try/bug_fixes/binary_identifier_serialize_value_debug_try.rb +98 -0
  110. data/try/bug_fixes/binary_scope_id_instance_scoped_audit_try.rb +88 -0
  111. data/try/bug_fixes/glob_prefix_key_patterns_try.rb +262 -0
  112. data/try/bug_fixes/hashkey_refresh_read_only_try.rb +104 -0
  113. data/try/bug_fixes/horreum_refresh_absent_fields_try.rb +124 -0
  114. data/try/bug_fixes/horreum_refresh_aliased_identifier_try.rb +120 -0
  115. data/try/bug_fixes/horreum_refresh_exception_safety_try.rb +140 -0
  116. data/try/bug_fixes/horreum_refresh_identifier_forms_try.rb +226 -0
  117. data/try/bug_fixes/horreum_refresh_identity_try.rb +254 -0
  118. data/try/bug_fixes/index_bucket_key_audits_try.rb +268 -0
  119. data/try/bug_fixes/invalid_scope_id_index_tracker_try.rb +108 -0
  120. data/try/bug_fixes/invalid_scope_id_nested_scope_audit_try.rb +85 -0
  121. data/try/bug_fixes/invalid_scope_id_non_ascii_value_audit_try.rb +92 -0
  122. data/try/bug_fixes/invalid_utf8_field_value_index_try.rb +115 -0
  123. data/try/bug_fixes/invalid_utf8_long_field_value_warning_try.rb +71 -0
  124. data/try/bug_fixes/multi_byte_delimiter_ownership_try.rb +90 -0
  125. data/try/bug_fixes/multi_index_blank_value_buckets_try.rb +341 -0
  126. data/try/bug_fixes/multi_index_blank_value_other_index_try.rb +109 -0
  127. data/try/bug_fixes/multi_index_rebuild_glob_scope_try.rb +370 -0
  128. data/try/bug_fixes/multi_index_record_ownership_try.rb +540 -0
  129. data/try/bug_fixes/multi_index_tail_index_name_ownership_try.rb +87 -0
  130. data/try/bug_fixes/object_key_type_scans_try.rb +223 -0
  131. data/try/bug_fixes/partial_write_index_maintenance_try.rb +19 -4
  132. data/try/bug_fixes/participation_entry_invalid_utf8_try.rb +118 -0
  133. data/try/bug_fixes/refresh_missing_key_try.rb +160 -0
  134. data/try/bug_fixes/refresh_operation_mode_try.rb +60 -0
  135. data/try/bug_fixes/unique_index_guard_identifier_bytes_try.rb +136 -0
  136. data/try/bug_fixes/unique_index_integer_identifier_try.rb +95 -0
  137. data/try/edge_cases/legacy_data_detection/deserialization_log_identifier_try.rb +197 -0
  138. data/try/edge_cases/legacy_data_detection/deserialize_value_override_try.rb +211 -0
  139. data/try/edge_cases/legacy_data_detection/find_by_id_legacy_string_try.rb +2 -2
  140. data/try/features/encrypted_fields/encrypted_fields_no_cache_security_try.rb +1 -0
  141. data/try/features/encryption/bundled_gem_independence_try.rb +99 -0
  142. data/try/features/relationships/multi_index_ownership_concurrent_declaration_try.rb +115 -0
  143. data/try/features/relationships/rebuild_via_scan_try.rb +15 -2
  144. data/try/features/relationships/relationships_performance_try.rb +13 -8
  145. data/try/features/schema_registry_try.rb +63 -0
  146. data/try/integration/connection/connection_scope_try.rb +594 -0
  147. data/try/integration/futures/admin_futures_try.rb +82 -0
  148. data/try/integration/futures/conditional_write_scripting_try.rb +100 -0
  149. data/try/integration/futures/expiration_futures_try.rb +113 -0
  150. data/try/integration/futures/features_futures_try.rb +70 -0
  151. data/try/integration/futures/hashkey_futures_try.rb +353 -0
  152. data/try/integration/futures/horreum_management_futures_try.rb +149 -0
  153. data/try/integration/futures/horreum_persistence_futures_try.rb +177 -0
  154. data/try/integration/futures/listkey_futures_try.rb +264 -0
  155. data/try/integration/futures/migration_futures_try.rb +166 -0
  156. data/try/integration/futures/related_field_predicates_futures_try.rb +101 -0
  157. data/try/integration/futures/relationships_futures_try.rb +469 -0
  158. data/try/integration/futures/scalar_futures_try.rb +416 -0
  159. data/try/integration/futures/sorted_set_futures_try.rb +320 -0
  160. data/try/integration/futures/subsecond_expiration_futures_try.rb +145 -0
  161. data/try/integration/futures/unsorted_set_futures_try.rb +187 -0
  162. data/try/integration/persistence_operations_try.rb +1 -0
  163. data/try/integration/transaction_safety_core_try.rb +6 -5
  164. data/try/integration/transaction_safety_workflow_try.rb +8 -7
  165. data/try/migration/model_try.rb +44 -0
  166. data/try/migration/schema_validation_try.rb +217 -0
  167. data/try/support/debugging/debug_aad_process.rb +1 -0
  168. data/try/support/debugging/debug_concealed_internal.rb +1 -0
  169. data/try/support/debugging/debug_concealed_reveal.rb +1 -0
  170. data/try/support/debugging/debug_context_aad.rb +1 -0
  171. data/try/support/debugging/debug_context_simple.rb +1 -0
  172. data/try/support/debugging/debug_cross_context.rb +1 -0
  173. data/try/support/debugging/debug_database_load.rb +1 -0
  174. data/try/support/debugging/debug_encrypted_json_check.rb +1 -0
  175. data/try/support/debugging/debug_encrypted_json_step_by_step.rb +1 -0
  176. data/try/support/debugging/debug_exists_lifecycle.rb +1 -0
  177. data/try/support/debugging/debug_field_decrypt.rb +1 -0
  178. data/try/support/debugging/debug_fresh_cross_context.rb +1 -0
  179. data/try/support/debugging/debug_load_path.rb +1 -0
  180. data/try/support/debugging/debug_method_resolution.rb +1 -0
  181. data/try/support/debugging/debug_provider.rb +1 -0
  182. data/try/support/debugging/debug_secure_behavior.rb +1 -0
  183. data/try/support/debugging/debug_test_design.rb +1 -0
  184. data/try/support/helpers/fresh_ruby.rb +28 -0
  185. data/try/support/helpers/test_helpers.rb +5 -4
  186. data/try/unit/atomic_operations_try.rb +36 -0
  187. data/try/unit/core/escape_glob_try.rb +107 -0
  188. data/try/unit/core/runtime_dependencies_try.rb +56 -0
  189. data/try/unit/horreum/base_try.rb +8 -5
  190. data/try/unit/horreum/related_field_lifecycle_try.rb +254 -1
  191. data/try/unit/horreum/serialization_try.rb +7 -0
  192. data/try/unit/utils/future_aware_helpers_try.rb +95 -2
  193. metadata +74 -38
  194. data/changelog.d/fragments/20260901_221800_delano_find_by_id_legacy_string.rst +0 -17
  195. data/changelog.d/fragments/20260902_043148_delano_406_valid_debug_log.md +0 -7
  196. data/changelog.d/fragments/20260902_043513_delano_407_deserialization_log_redaction.md +0 -7
  197. data/changelog.d/fragments/20260902_120000_delano_405_envelope_provenance.rst +0 -32
  198. data/changelog.d/fragments/20260902_130000_delano_408_envelope_encoding_validation.rst +0 -26
  199. data/changelog.d/fragments/20260904_120000_delano_security_audit_anchors.rst +0 -4
  200. data/changelog.d/fragments/20260904_150000_delano_encryption_identifier_precondition.rst +0 -6
  201. data/changelog.d/fragments/20260904_160000_delano_rebuild_via_scan_failure.rst +0 -4
  202. data/changelog.d/fragments/20260905_000000_delano_external_identifier_format_validation.rst +0 -4
  203. data/changelog.d/fragments/20260905_000500_delano_concurrent_rebuild_temp_key.rst +0 -78
  204. data/changelog.d/fragments/20260905_111500_delano_identifier_secret_env.rst +0 -16
  205. data/changelog.d/fragments/20260905_213000_delano_related_field_lifecycle.rst +0 -104
  206. data/changelog.d/fragments/20260906_203000_delano_unique_index_rebuild_fallback.rst +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 889cca334ccb762d4a5cb4fa64a400c3df8727a8219bf19129b6699bf4056069
4
- data.tar.gz: c2e315e109a866d8b1943be21fb7cf215c3db767e801138fa4097229e98c5dcb
3
+ metadata.gz: 78abdfef8c91211092845604967187b510a3b01f8b00772340cfb1c6ff081819
4
+ data.tar.gz: 8e982398843fb0c227be798af2c3b6de60b2daf40a6d9e8791ac4c122e586f55
5
5
  SHA512:
6
- metadata.gz: d7e65ba3aa75c03d7c0328a34f7c6ad13f9c840efc2aefbe0c06bba678a41ac27704928962b5d9a3be769bd7df186bf1bc56647ee80aa173a1000ff6404d599e
7
- data.tar.gz: e34f5200cac7681ff999b31cf842d326e49882ffd823602d8d677bec95b6376edca29f967f9da494eb431f9a857bc81758519dd4fd5969714ed478bd085cdf54
6
+ metadata.gz: 0d612da7f16d268eb3b214a43cfa8f53c26e7766e70294ce3dabd134e2ca1c065726811f87c0e4dccb2aaacb463aba05a3e13148c7d8b6578933361c1c62c9e8
7
+ data.tar.gz: 39a032c23215875127fb97cdcbb7501ee28a9e624df60e81ab5feab700feeb1fdeae98df28bfbf660271d622aa77a74a3004ea9a4b8061345e0972e40ecb4094
@@ -13,3 +13,8 @@ updates:
13
13
  directory: "/" # Location of package manifests
14
14
  schedule:
15
15
  interval: "weekly"
16
+ ignore:
17
+ # dry-configurable 1.4.0 requires Ruby >= 3.3. Remove with Ruby 3.2
18
+ # support (see the Gemfile).
19
+ - dependency-name: "dry-configurable"
20
+ versions: [">= 1.4"]
@@ -13,6 +13,92 @@ permissions:
13
13
  contents: read
14
14
 
15
15
  jobs:
16
+ # The repo's own bundle includes development and test gems, which can supply
17
+ # a library that familia requires but does not declare (base64 arrived this
18
+ # way, through ruby-prof). This job bundles familia alone, as an application
19
+ # does, and fails if it cannot be required or if Ruby warns that a required
20
+ # library is leaving the default gems.
21
+ consumer-load:
22
+ timeout-minutes: 10
23
+
24
+ runs-on: ubuntu-24.04
25
+
26
+ strategy:
27
+ fail-fast: false
28
+ matrix:
29
+ ruby: ["3.2", "3.3", "3.4", "4.0"]
30
+
31
+ steps:
32
+ - uses: actions/checkout@v7
33
+ - name: Set up Ruby
34
+ uses: ruby/setup-ruby@v1
35
+ with:
36
+ ruby-version: ${{ matrix.ruby }}
37
+
38
+ - name: Require familia from a bundle that contains only familia
39
+ run: |
40
+ consumer="$RUNNER_TEMP/consumer"
41
+ mkdir -p "$consumer"
42
+ cd "$consumer"
43
+ printf "source 'https://rubygems.org'\ngem 'familia', path: '%s'\n" "$GITHUB_WORKSPACE" > Gemfile
44
+ bundle install
45
+ if ! out=$(bundle exec ruby -e 'require "familia"; puts "familia #{Familia::VERSION} loaded"' 2>&1); then
46
+ echo "$out"
47
+ echo "::error::require 'familia' failed in a bundle that contains only familia"
48
+ exit 1
49
+ fi
50
+ echo "$out"
51
+ if grep -q "loaded from the standard library" <<<"$out"; then
52
+ echo "::error::familia requires a library that is leaving Ruby's default gems without declaring it in familia.gemspec"
53
+ exit 1
54
+ fi
55
+
56
+ # An application can resolve any release familia.gemspec allows, not only
57
+ # the newest one consumer-load gets. This job pins each runtime dependency
58
+ # to the lowest release the gemspec allows and fails if familia, or
59
+ # json_schemer (which familia requires on first schema validation), cannot
60
+ # be required. Ruby 3.4 is the first release that raises LoadError under
61
+ # Bundler for libraries that left the default gems (base64, bigdecimal).
62
+ # Warnings are not checked here: oj 3.16.2 to 3.16.4 warn about ostruct
63
+ # but load, and consumer-load checks warnings with the newest releases.
64
+ consumer-load-lowest:
65
+ timeout-minutes: 10
66
+
67
+ runs-on: ubuntu-24.04
68
+
69
+ steps:
70
+ - uses: actions/checkout@v7
71
+ - name: Set up Ruby
72
+ uses: ruby/setup-ruby@v1
73
+ with:
74
+ ruby-version: "3.4"
75
+
76
+ - name: Require familia with the lowest dependency releases it allows
77
+ run: |
78
+ consumer="$RUNNER_TEMP/consumer-lowest"
79
+ mkdir -p "$consumer"
80
+ cd "$consumer"
81
+ ruby - "$GITHUB_WORKSPACE" > Gemfile <<'RUBY'
82
+ workspace = ARGV.fetch(0)
83
+ spec = Dir.chdir(workspace) { Gem::Specification.load('familia.gemspec') }
84
+ puts "source 'https://rubygems.org'"
85
+ puts "gem 'familia', path: #{workspace.dump}"
86
+ spec.runtime_dependencies.each do |dep|
87
+ found, = Gem::SpecFetcher.fetcher.spec_for_dependency(dep)
88
+ lowest = found.map { |s, _source| s.version }.min or abort "no release satisfies #{dep}"
89
+ puts "gem #{dep.name.dump}, #{lowest.to_s.dump}"
90
+ end
91
+ RUBY
92
+ cat Gemfile
93
+ bundle install
94
+ probe='require "familia"; require "json_schemer"; puts "familia #{Familia::VERSION} and json_schemer #{JSONSchemer::VERSION} loaded"'
95
+ if ! out=$(bundle exec ruby -e "$probe" 2>&1); then
96
+ echo "$out"
97
+ echo "::error::familia or json_schemer failed to load with the lowest dependency releases familia.gemspec allows"
98
+ exit 1
99
+ fi
100
+ echo "$out"
101
+
16
102
  build:
17
103
  timeout-minutes: 10
18
104
 
@@ -48,7 +48,7 @@ jobs:
48
48
 
49
49
  - name: Run Claude Code Review
50
50
  id: claude-review
51
- uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1.0.211
51
+ uses: anthropics/claude-code-action@d70510b174a1244958df0b03166a54912811f3e6 # v1.0.264
52
52
  with:
53
53
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
54
54
 
@@ -32,7 +32,7 @@ jobs:
32
32
 
33
33
  - name: Run Claude Code
34
34
  id: claude
35
- uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1.0.211
35
+ uses: anthropics/claude-code-action@d70510b174a1244958df0b03166a54912811f3e6 # v1.0.264
36
36
  with:
37
37
  claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38
38
 
data/.rubocop.yml CHANGED
@@ -30,6 +30,9 @@ AllCops:
30
30
  - "rspec/**/*.rb"
31
31
  - "tmp/**/*.rb"
32
32
  - "try/**/*.rb"
33
+ # Setting Exclude here replaces RuboCop's default list, which excludes
34
+ # vendor/. CI installs gems into vendor/bundle inside the checkout.
35
+ - "vendor/**/*"
33
36
 
34
37
  #
35
38
  # Trailing commas
data/AGENTS.md CHANGED
@@ -173,8 +173,9 @@ in one MULTI/EXEC; collection mutations auto-route into the open transaction via
173
173
  `Fiber[:familia_transaction]`. Constraints:
174
174
 
175
175
  - All related DataTypes must share the parent's `logical_database`, else `Familia::CrossDatabaseError` (fall back to `save_with_collections`). MULTI/EXEC is single-database only.
176
- - Cannot nest inside another `transaction`/`atomic_write` (`Familia::OperationModeError`).
176
+ - Cannot nest inside another `transaction`/`atomic_write` or run inside a `pipelined` block (`Familia::OperationModeError`).
177
177
  - Collection return values inside the block are `Redis::Future` — do not inspect before EXEC.
178
+ - Methods that need a reply to continue (iterators, finders and loaders, `fetch`, `refresh!`, the partial writers) raise `Familia::OperationModeError` inside the block. The policy and method lists are in docs/reference/transaction_safety.md, rule 4.
178
179
 
179
180
  **Factory — `build` for create-and-populate:**
180
181
 
data/CHANGELOG.rst CHANGED
@@ -7,6 +7,222 @@ 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.14.0.pre1:
11
+
12
+ 2.14.0.pre1 — 2026-11-03
13
+ ========================
14
+
15
+ Added
16
+ -----
17
+
18
+ - Added ``Familia::IdentifierMismatchError`` (a ``Familia::HorreumError``), with ``#key`` and ``#resolved_key``. Its message shows a key whose bytes are not valid UTF-8 with every invalid byte written as ``\xNN``. #445 #453
19
+ - ``Horreum#deserialize_value`` accepts ``dbkey:``, the key the value was read from. The error log entry for a value that is not JSON names it. Familia passes ``dbkey:`` only when every ``deserialize_value`` in the object's super chain accepts it, by name or through ``**``; an override with the 2.13.0 signature, ``deserialize_value(val, symbolize: false, field_name: nil)``, is called without it. #445
20
+ - Added ``Familia.escape_glob(str)``, which escapes ``*``, ``?``, ``[``, ``]`` and ``\`` for use in KEYS and SCAN MATCH patterns. It escapes bytes and keeps the input's encoding, so it accepts strings with invalid UTF-8 and binary data. #446
21
+ - Added ``Familia::Horreum.dbkey_pattern(identifier_glob = '*', key_suffix = suffix)``, which returns a key pattern with the class prefix, delimiter and suffix escaped, and ``Familia::Horreum::OBJECT_KEY_TYPE`` (``"hash"``), the Redis type of an object key. #446
22
+ - Added ``Familia::IndexBucketConflictError`` (a ``Familia::PersistenceError``), whose ``conflicts`` maps each key to the type it holds, or to ``:too_many_candidate_owners`` for a key whose owner was not checked, and whose ``owners`` maps each key of another record to that record's hash key. #446
23
+ - Added ``Familia::Features::Relationships::Indexing::RecordKeyOwnership::MAX_CANDIDATE_SPLITS`` (8), the most candidate owner records the ``multi_index`` rebuild and audits check for one key, and ``Familia::Features::Relationships::Indexing::RecordKeyOwnership::MAX_CANDIDATE_BATCH_BYTES`` (1 MiB), the most bytes of candidate owner identifiers they check in one pipeline. An identifier longer than that is checked in a pipeline of its own. #446
24
+ - ``audit_multi_indexes`` results include ``foreign_owned_keys``: the sets under the bucket prefix that the rebuild leaves alone, each with ``key``, ``field_value``, ``owner_key`` and ``reason`` (``:other_record``, or ``:too_many_candidate_owners`` with a nil ``owner_key``), plus ``scope_id`` for an instance-scoped index. An entry without an ``owner_key`` sets the status to ``:issues_found`` and makes ``AuditReport#healthy?`` false. Each ``multi_indexes`` entry of ``AuditReport#to_h`` has ``:foreign_owned_keys``, the number of these keys, and each ``multi_index`` line of ``AuditReport#to_s`` ends with ``foreign_owned_keys=<n>``. They had neither. #446
25
+ - Added ``Familia::Migration::Model#scan_type``, the SCAN ``TYPE`` option of a model migration. It defaults to ``"hash"`` when ``@scan_pattern`` is left unset and to ``nil`` (every type) otherwise. #446
26
+ - Added ``Familia.transform_reply(reply) { |r| ... }``, which converts a concrete command reply and returns a ``Redis::Future`` untouched. #447
27
+ - Added ``Familia.transaction_or_pipeline?``, which reports whether the current fiber is inside a Familia transaction or pipeline block, and ``Familia.assert_replies_available!(operation, conn: nil)``, which raises ``Familia::OperationModeError`` there. When ``conn`` is given it raises only when ``conn`` is a transaction or pipeline connection. #447
28
+ - Added ``Familia::SchemaValidatorLoadError``, a ``Familia::Problem`` whose ``cause`` is the ``LoadError`` that stopped ``json_schemer`` from loading. #448
29
+ - Added ``Familia.normalize_key_encoding(str)``, which returns ``str`` as UTF-8 when its bytes are valid UTF-8 and as BINARY otherwise, the encoding redis-client gives a key or member it reads. The bytes are unchanged. #453
30
+ - Added ``Familia.with_connection_scope(uri = nil) { |client| ... }``. While the block runs, every ``dbclient`` lookup in the calling fiber that would otherwise reach ``CreateConnectionHandler`` (no open transaction or pipeline, no fiber-local connection, no ``connection_provider``, no class-level ``@dbclient``) resolves to one ``Redis`` client per distinct connection configuration (keyed by ``URI#conf``), created on first use with ``Familia.create_dbclient``. The scope closes every client it created when the block exits, on return and on exception. The block receives what ``Familia.dbclient(uri)`` returns inside the scope: the scope's client for ``uri`` (``Familia.uri`` when nil), or, when an open transaction or pipeline, a fiber-local connection or a ``connection_provider`` answers first, that connection, and the scope creates no client for it. A nested call in the same fiber joins the open scope. ``transaction``, ``pipelined`` and ``atomic_write`` inside the block run on the scope's client for their URI when their connection lookup reaches the scope. #456
31
+ - Inside a ``with_connection_scope`` block, between the WATCH and the EXEC of a WATCH-guarded transaction, other lookups get a second scope client for that URI, so a transaction they run cannot clear the WATCH. Inside ``Horreum#watch``, issue the MULTI/EXEC, UNWATCH and DISCARD on the yielded connection; the receiver's own ``transaction``, ``unwatch`` and ``discard`` run on the second client and are not guarded by the WATCH. #456
32
+ - Threads and fibers spawned inside a ``with_connection_scope`` block are not the owner and resolve connections as if no scope were open. An open transaction or pipeline, a fiber-local connection, a ``connection_provider`` and a class-level ``@dbclient`` take precedence over the scope. Connection resolution outside a scope is unchanged: without a provider, lookups still create fresh clients. #456
33
+ - A ``Familia.middleware_version`` bump (``Familia.reconnect!``) makes an open connection scope retire its clients on the next lookup: new lookups get fresh clients and the retired ones stay open until the block exits. #456
34
+ - Added ``Familia.current_connection_scope``, returning the ``Familia::Connection::Scope`` the current fiber has open, or nil. #456
35
+ - Added ``Familia::Connection::Scope`` with ``owner``, ``clients``, ``size``, ``active?``, ``released?`` and ``release!``. ``release!`` closes every held client and is safe to call more than once; a later ``Scope#client`` call raises ``Familia::Problem``, as does a ``client`` or ``release!`` call from a fiber other than the owner. #456
36
+ - Added ``Familia::Connection::ScopedConnectionHandler`` (``supports transaction: true, pipelined: true``), placed after ``ProviderConnectionHandler`` in the ``Familia.dbclient`` chain and after ``CachedConnectionHandler`` in the Horreum class chain, before ``CreateConnectionHandler`` in both. The DataType chain is unchanged; it reaches the scope through the parent's chain or ``Familia.dbclient``. #456
37
+ - Added ``with_connection_scope(uri = nil)`` to Horreum classes and DataType instances through ``Familia::Connection::Behavior``, and to Horreum instances, which forward to their class. It opens the same scope and yields the client resolved through the receiver's own chain, so a class-level ``@dbclient`` or ``logical_database`` setting is honoured. #456
38
+
39
+ Changed
40
+ -------
41
+
42
+ - ``familia`` now requires ``redis`` ``5.4`` or later (``>= 5.4, < 6.0``, was ``>= 5.0``). ``Lock#release``, and inside a block ``HashKey#hsetnx``, ``JsonStringKey#setnx`` and ``JsonStringKey#value`` with a default, now pass a reply-coercion block to ``Redis#call``. Earlier ``redis`` versions ignore the block, so with them these methods would return, or their Futures resolve to, ``1`` or ``0`` instead of ``true`` or ``false``. See ``docs/migrating/runtime-dependencies.md``. #447
43
+ - ``familia.gemspec`` requires ``oj`` ``~> 3.16, >= 3.16.2`` (was ``~> 3.16``) and ``json_schemer`` ``~> 2.2`` (was ``~> 2.0``). Bundles that pin an older ``oj`` or ``json_schemer`` no longer resolve. The excluded ``json_schemer`` ``2.0.0`` to ``2.1.1`` require ``base64`` without declaring it; now that ``require 'familia'`` no longer loads ``base64``, they would fail to load on Ruby 3.4+ in a bundle without it. See ``docs/migrating/runtime-dependencies.md``. #448
44
+ - ``require 'familia'`` no longer loads ``base64``. Applications that call ``Base64`` must ``require 'base64'`` themselves and, on Ruby 3.4+, list ``base64`` in their Gemfile. See ``docs/migrating/runtime-dependencies.md``. #448
45
+ - ``Horreum#refresh!`` and ``Horreum#refresh`` raise ``Familia::KeyNotFoundError`` when the record's key does not exist, as documented, and leave the object unchanged. They returned normally (``refresh!`` returned ``[]``), kept the in-memory persistent field values, including an ``objid`` generated to build the key, reset transient fields to ``nil`` and cleared dirty tracking. Code that refreshes a record that may not be saved yet must rescue the error or use ``find_by_id``, which returns ``nil``. See ``docs/migrating/refresh.md``. #443 #445
46
+ - ``HashKey#refresh!`` and ``HashKey#refresh`` raise ``Familia::KeyNotFoundError`` for a missing key instead of ``Redis::CommandError`` (``ERR wrong number of arguments for 'hmset' command``). Update ``rescue`` clauses. See ``docs/migrating/refresh.md``. #443
47
+ - ``Horreum#refresh!``, ``Horreum#refresh``, ``HashKey#refresh!`` and ``HashKey#refresh`` raise ``Familia::OperationModeError`` inside a transaction, pipeline or ``atomic_write`` block, before sending any command. They raised ``NoMethodError`` on the ``Redis::Future`` returned by ``HGETALL``. See ``docs/migrating/refresh.md``. #445 #447
48
+ - ``Horreum#refresh!`` and ``Horreum#refresh`` set every persistent field that the stored hash does not contain to ``nil``, as ``load`` and ``find_by_id`` do. They kept the in-memory value and then cleared dirty tracking. This covers a value set in memory and never saved, including an ``objid`` or ``extid`` generated on first read, a field another writer removed, and a default that an ``init`` hook set on an object built with ``new``. ``objid_generator_used`` is reset to ``nil`` with ``objid``. A Symbol or String identifier field that is not stored keeps its value, including a field named by its ``as:`` method. See ``docs/migrating/refresh.md``. #445
49
+ - ``Horreum#refresh!`` and ``Horreum#refresh`` no longer call ``Horreum#naive_refresh`` or ``Horreum#initialize_with_keyword_args_deserialize_value``, and ``find_by_id``, ``find_by_dbkey``, ``load_multi`` and ``load_multi_by_keys`` no longer call ``initialize_with_keyword_args_deserialize_value``, so an application override of either method does not run when they load a record. A direct ``naive_refresh`` call still calls ``initialize_with_keyword_args_deserialize_value``. #445
50
+ - ``Horreum#refresh!`` and ``Horreum#refresh`` raise ``Familia::IdentifierMismatchError`` and leave the object unchanged when the stored values give no identifier or one that names a different key, for example a stored identifier field that differs from the key, or a missing field that a Proc or method identifier reads. They assigned a stored identifier that differed from the key, so a later ``save`` wrote to another record or created a new one. See ``docs/migrating/refresh.md``. #445
51
+ - ``HashKey#refresh!`` returns the fields it read as a ``Hash``, as ``HashKey#hgetall`` returns them, instead of the ``HMSET`` reply ``"OK"``. #445
52
+ - ``HashKey#refresh!`` and ``HashKey#refresh`` no longer reset the key's expiration. Call ``update_expiration`` to extend it. See ``docs/migrating/refresh.md``. #445
53
+ - ``HashKey#refresh!`` and ``HashKey#refresh`` no longer run the dirty-write check against the parent, so they no longer warn or raise ``Familia::Problem`` when the parent has unsaved fields. #445
54
+ - ``all``, ``scan_pattern``, ``keys_count``, ``scan_count``, ``keys_any?``, ``scan_any?`` and ``scan_keys`` escape the class prefix, delimiter and suffix. Identifier filters are still glob patterns. The ``all`` and ``scan_pattern`` arguments are now literal suffixes. #446
55
+ - ``scan_pattern(nil)`` leaves the suffix out, as ``dbkey`` does, and returns ``"customer:*"`` instead of ``"customer:*:"``. #446
56
+ - ``find_keys`` escapes the class prefix and delimiter. Its suffix argument is still a glob pattern. #446
57
+ - ``all``, ``keys_count``, ``scan_count``, ``keys_any?``, ``scan_any?``, ``scan_keys``, ``audit_instances``, ``rebuild_instances``, the unique-index SCAN rebuild fallback and model migrations with the default ``@scan_pattern`` use only matching keys that hold a hash. ``all``, ``keys_count`` and ``keys_any?`` send ``KEYS`` and then, in one pipeline, one ``TYPE`` per returned key; they sent no ``TYPE``. #446
58
+ - ``Familia::Migration::Model``, and so ``Familia::Migration::Pipeline``, default ``@scan_pattern`` to the model class's ``scan_pattern``, which follows ``Familia.delim`` and the class suffix instead of a hardcoded ``:`` and ``object``. For a class with another suffix or a ``Familia.delim`` other than ``:``, a migration that leaves ``@scan_pattern`` unset now processes the class's records, so an actual run applies its changes to them. The 2.13.0 pattern ``<prefix>:*:object`` matched none of them, so such a migration processed no records and ``Migration::Runner`` still recorded it as applied. ``Runner#run`` skips a migration recorded as applied; ``Runner#run_one`` runs it again. #446
59
+ - The generated ``rebuild_<index>`` of a ``multi_index`` checks every bucket key it will write before it deletes anything. If one holds a type other than set, or is a set field or ``multi_index`` bucket of an existing record of the same class with a longer identifier, it raises ``Familia::IndexBucketConflictError`` and leaves the index and that key unchanged. It raises the same way, before deleting anything, when a key it would write or delete has more than ``MAX_CANDIDATE_SPLITS`` candidate owner records. #446
60
+ - The ``rebuild_<index>`` of a ``multi_index`` reports a bucket key to its progress block in the ``:clearing`` phase, and counts it, only when its ``DEL`` removed the key. #446
61
+ - ``repair_multi_indexes!`` records a rebuild that raises ``Familia::IndexBucketConflictError`` in ``:skipped`` with ``reason: :bucket_conflict``, ``conflicts``, ``owners`` and, for an instance-scoped index, ``scope_id``, and goes on with the next scope or index. ``repair_all!`` returns status ``:partial_failure`` for such an entry. #446
62
+ - The ``multi_index`` rebuild and ``audit_multi_indexes`` pass the ``TYPE`` option to SCAN when they look for bucket sets. ``audit_related_fields`` and ``audit_participations`` pass it for the type the field or collection stores, so keys of another type are not reported or repaired. #446
63
+ - ``scan_count`` (``count!``), ``scan_any?`` (``any!``), ``scan_keys``, ``rebuild_instances``, ``audit_instances``, ``audit_unique_indexes``, ``audit_multi_indexes``, ``audit_participations``, ``audit_related_fields``, ``health_check``, ``repair_all!`` and ``repair_multi_indexes!``; ``repair_instances!``, ``repair_indexes!``, ``repair_participations!`` and ``repair_related_fields!`` called without audit results; the generated ``rebuild_<index>`` of a ``multi_index``; the unique-index SCAN rebuild fallback; and ``Familia::Migration::Model`` and ``Familia::Migration::Pipeline`` migrations with the default ``@scan_pattern`` or a ``@scan_type`` send SCAN with the ``TYPE`` option. They sent SCAN without ``TYPE``. Redis added the ``TYPE`` option to SCAN in 6.0.0, so these calls need Redis 6.0 or later, or another server whose SCAN accepts ``TYPE``. README.md lists Valkey/Redis 6.0+ as a prerequisite in both versions. #446
64
+ - ``audit_related_fields`` sends ``TYPE`` for the hash key of the record a related-field key names and counts the record as existing only when that key holds a hash. When it holds another type, such as a string, set, list or sorted set, ``audit_related_fields`` and ``health_check(audit_collections: true)`` report the related-field key in ``orphaned_keys``, unless they skip it as a ``multi_index`` bucket (see Security), and ``repair_related_fields!`` and ``repair_all!(audit_collections: true)`` delete it with ``DEL``; the record's hash key stays. It sent ``EXISTS``, which counts a key of any type, so none of them reported or deleted such a related-field key. #446
65
+ - The instance-scoped ``add_to_*`` and ``update_in_*`` of a ``multi_index`` no longer add an object whose field value is empty or whitespace only to ``<scope key>:<index>``, the key ``<index>_for`` names for a blank value, as the class-level methods already did. They still record the membership, so ``save`` indexes the object once its value is no longer blank. Inside a transaction, ``add_to_*`` with such a value adds one reply fewer to ``MultiResult#results``, since it queues no ``SADD``. With a ``nil`` value they add and record nothing, as before. The instance-scoped ``find_all_by_*`` and ``sample_from_*`` return ``[]`` for a blank value (``nil``, empty or whitespace only), as the class-level ``sample_from_*`` already did. They returned objects from ``<scope key>:<index>``. #446
66
+ - Keys and related-field members that the instance-scoped ``add_to_*`` and ``update_in_*`` of a ``multi_index`` wrote for blank values before stay in place after upgrading: ``update_in_*`` with a blank old value and ``remove_from_*`` with a blank value no longer send ``SREM`` for the object's identifier to ``<scope key>:<index>``, and ``Horreum#destroy!``, or a ``save`` after the object's hash expired or was deleted, no longer sends it for a blank value the object recorded. The methods of that scope do not read ``<scope key>:<index>`` as data, because it can also be a related field of the scope class with the index's name. When the scope identifier contains the delimiter, the key can also be a bucket of an index of a shorter scope. When the scope identifier is the name of a class-level index of the scope class, or starts with that name and the delimiter, the key can also be a class-level bucket of that index. The audit of that other index reads such a key and its rebuild deletes or rewrites it, unless the scope class declares a set field named ``<index>`` and the scope record with that identifier exists. Familia does not tell such a key apart from a live bucket or a related field; see the indexing guide's "Blank field values" section. #446
67
+ - Inside ``transaction``, ``atomic_write`` and ``pipelined`` blocks, DataType and Horreum methods that only convert a reply now return the command's ``Redis::Future`` instead of raising ``NoMethodError``. Its value is the reply as redis-rb returns it, without Familia's conversion (for example ``empty?`` resolves to the count). Affected: ``empty?`` on every collection, the generated related-field predicates (``user.tags?``, ``User.instances?``), ``HashKey#values``, ``#hgetall``, ``#values_at``, ``#scan``, ``#randfield`` with ``withvalues: true``; ``ListKey#range``, ``#members``, ``#[]`` (and ``#slice``) with a range or a start and length, ``#member?``, ``#pop`` and ``#shift`` with a count; ``SortedSet#score``, ``#member?``, ``#rank``, ``#revrank``, ``#members``, ``#revmembers``, the range readers, ``#at``, ``#first``, ``#last``, ``#popmin``, ``#popmax``, ``#mscore``, ``#union``, ``#inter``, ``#diff``, ``#randmember``, ``#scan``; ``UnsortedSet#members``, ``#intersection``, ``#union``, ``#difference``, ``#scan``, ``#sample``; ``StringKey#size``, ``#empty?``; ``JsonStringKey#char_count``, ``#empty?``; ``Counter#value``, ``#reset``; ``expired?``; ``Horreum.any?``, ``.in_instances?``, ``.multiget``, ``.storage_inspect``; ``Migration::Registry#applied_at``, ``#all_applied``, ``#metadata``; the generated participation methods ``score_in_<target>_<collection>`` and ``in_<target>_<collection>?`` on a sorted-set or list participation. Return values outside a block are unchanged, except ``HashKey#randfield(count, withvalues: true)`` and ``SortedSet#diff(withscores: true)`` (see Fixed). #447
68
+ - Methods that need a reply to continue now raise ``Familia::OperationModeError`` inside ``transaction``, ``atomic_write`` and ``pipelined`` blocks instead of ``NoMethodError`` or a wrong result: ``each`` and the raw iterators on every collection, Ruby's conversion methods (``StringKey#to_s`` and ``#to_i``, which ``Counter`` and ``Lock`` inherit, ``JsonStringKey#to_s``, ``#to_i`` and ``#to_f``, ``to_a`` on ``ListKey``, ``SortedSet`` and ``UnsortedSet``, and ``as_json`` and ``to_json`` on every DataType; a scalar's ``as_json`` returned the GET Future and its ``to_json`` raised ``Familia::SerializerError``), ``HashKey#fetch``, ``Counter#increment_if_less_than``, ``Lock#locked?``, ``Lock#empty?`` (and so the generated predicate of a ``lock`` field), ``Lock#held_by?`` (returned ``false``, even for the holder), ``extend_expiration`` (returned ``false``) and ``ttl_report``. #447
69
+ - The finders and loaders (``find_by_dbkey``, ``find_by_identifier``, ``load_multi``, ``load_multi_by_keys``, ``all``, ``find_by_objid``, ``find_by_extid``), ``keys_count``, ``keys_any?``, ``scan_count``, ``scan_any?``, class-level ``destroy!``, the index finders, rebuilds and ``guard_unique_*!`` methods, the participation readers, ``current_indexings`` (reported every class-level index whose field was set), ``relationship_status``, ``validate_relationships!``, the ``audit_*``, ``health_check``, ``repair_*`` and ``scan_keys`` methods, the index format checks ``IndexDescriptor#stale_format?``, ``#format_current?``, ``Familia.stale_indexes`` and ``Familia.assert_indexes_current!`` (raised ``TypeError`` after queueing HRANDFIELD), ``AtomicOperations.atomic_swap``, ``.sweep_orphaned_temp_keys`` and ``.with_rebuild`` given a transaction or pipeline connection (``with_rebuild`` raised ``NoMethodError`` after queueing its lock), ``run_chores!``, ``EnforceCollectionCaps``, ``Migration::Base.run`` and ``.check_only``, and ``Migration::Runner#run``, ``#run_one``, ``#rollback``, ``#status`` and ``#pending`` also raise ``Familia::OperationModeError`` inside ``transaction``, ``atomic_write`` and ``pipelined`` blocks instead of ``NoMethodError`` or a wrong result. #447
70
+ - ``Migration::Registry#pending``, ``#status``, ``#record_rollback``, ``#schema_changed?``, ``#schema_drift`` and ``#restore_backup`` raise ``Familia::OperationModeError`` when the registry's client is a transaction or pipeline connection. #447
71
+ - ``Migration::Registry#client`` without ``redis:`` returns the current transaction or pipeline connection inside a Familia block without keeping it, so it no longer keeps a connection from a block that has completed. Outside a block it still keeps the first ``Familia.dbclient`` it resolves, and ``#redis`` returns that kept connection (or the ``redis:`` client), never a transaction or pipeline connection. #447
72
+ - A ``Migration::Registry`` built without ``redis:``, as ``Migration::Runner`` builds its default, and first used outside a block kept a plain connection for life: inside a later block its commands ran at once, outside the MULTI, and every registry method returned its usual value. Inside a block such a registry now returns the ``Redis::Future`` from ``applied?``, ``applied_at``, ``all_applied`` and ``metadata`` (``applied?`` resolves to the ZSCORE score or ``nil``, and the Future is truthy), queues ``record_applied`` and returns its Future, and raises ``Familia::OperationModeError`` from ``pending``, ``status``, ``record_rollback``, ``schema_changed?``, ``schema_drift`` and ``restore_backup``. A registry first used inside a block returned ``true`` from ``applied?`` for every migration and kept the block's connection, so a later ``record_applied`` returned a Future and wrote nothing. The block's connection is on the database the block was opened on, so inside a block opened by a model with another ``logical_database`` such a registry's commands go to that database. To keep a registry on its own connection, pass one: ``Familia::Migration::Registry.new(redis: Familia.create_dbclient)``. #447
73
+ - ``save``, ``save_if_not_exists!``, ``create!``, ``build``, ``atomic_write`` and ``Familia.atomic_write`` raise ``Familia::OperationModeError`` inside a pipeline as well as a transaction. Inside a pipeline they previously raised ``Familia::ConflictingContextError`` or a spurious ``Familia::RecordExistsError``. #447
74
+ - Inside a pipeline, the ``add_to_*`` and ``update_in_*`` methods of a ``unique_index`` raise ``Familia::OperationModeError`` before queueing anything, and the error names the method called. ``claim_unique_*!`` names itself the same way inside a transaction or pipeline. Inside a pipeline they previously raised a spurious ``Familia::RecordExistsError`` (``add_to_*`` on an instance-scoped index), ``Familia::ConflictingContextError`` (``update_in_*`` with no field value) or ``Familia::OperationModeError`` naming ``claim_field``, and ``add_to_*`` with no field value returned ``nil``. Inside a transaction they behave as before. #447
75
+ - ``commit_fields``, ``save_fields``, ``multi_field_update`` and ``multi_field_fast_write`` raise ``Familia::OperationModeError`` inside any transaction or pipeline, before changing state. Inside a caller's transaction, ``multi_field_update`` and ``multi_field_fast_write`` previously queued their write, which committed with the outer EXEC; call them before the block or use ``atomic_write``. ``commit_fields`` and ``save_fields`` raised ``NoMethodError`` there. #447
76
+ - The generated staged participation methods (``stage_<name>_instance``, ``stage_<name>``, ``activate_<name>_instance``, ``unstage_<name>_instance``, ``unstage_<name>``) and the add and remove methods of a ``through:`` participation raise ``Familia::OperationModeError`` inside any transaction or pipeline before queueing anything, and the error names the generated method. Inside a caller's transaction, ``unstage_<name>_instance`` previously queued its deletes, which committed with the outer EXEC, and returned ``true``; call it before or after the block. The others raised ``NoMethodError``, ``Familia::ConflictingContextError`` or an error naming an internal call, some after queueing part of their writes. #447
77
+ - ``Horreum#destroy!`` raises ``Familia::OperationModeError`` inside a transaction or pipeline, before queueing anything, when the class has instance-scoped indexes. It previously raised ``NoMethodError`` after queueing any ``object_identifier`` and ``external_identifier`` lookup deletes. Other classes still queue their deletes into the caller's transaction. #447
78
+ - ``ListKey#pushx`` and ``#unshiftx`` queue their TTL refresh inside a transaction or pipeline instead of skipping it. ``SortedSet#popmin`` and ``#popmax``, which raised ``NoMethodError`` there, queue theirs too, except with a count that pops nothing (``0`` or negative), as outside a block. #447
79
+ - Inside a transaction or pipeline, ``HashKey#hsetnx``, ``ListKey#insert``, ``JsonStringKey#setnx`` and ``JsonStringKey#value`` with a default queue their write and TTL refresh as one ``EVAL`` that refreshes only when the write takes effect, as outside a block. ``hsetnx`` and ``insert`` previously skipped the refresh there; ``JsonStringKey#setnx`` and ``#value`` reset an existing key's TTL even when the key was already set. The returned Futures resolve to the same values as before. #447
80
+ - Inside a transaction or pipeline, when the ``json_string`` has a default expiration (a positive ``default_expiration``, its own or inherited, and no ``no_expiration: true``), ``JsonStringKey#setnx`` now adds one reply to ``MultiResult#results`` instead of two and ``#value`` with a default adds two instead of three (the separate ``EXPIRE`` reply is gone), so later replies move up one index; without a default expiration ``#setnx`` adds one reply and ``#value`` with a default adds two, as before. ``ListKey#pushx`` and ``#unshiftx`` on a list with a default expiration added one reply, the count, and now add two, the count then the ``EXPIRE`` reply, so the index of every later reply increases by one; without a default expiration they add one as before. ``SortedSet#popmin`` and ``#popmax``, which raised ``NoMethodError`` there, add the ZPOPMIN or ZPOPMAX reply and, with a default expiration and a count above ``0``, the ``EXPIRE`` reply after it. #447
81
+ - Inside a pipeline, the instance-scoped ``add_to_*`` and the class-level ``add_to_class_*`` of a ``multi_index`` no longer queue ``EXISTS`` on the object's key, so for a value that is not blank they add one reply fewer to ``MultiResult#results`` and later replies move up one index; for an empty or whitespace-only value, ``add_to_*`` adds only the reply of the ``HSET`` that records the membership instead of the ``EXISTS``, ``SADD`` and ``HSET`` replies. An unsaved object is indexed there without ``Familia::PersistenceError``, as before. Inside a transaction they did not queue ``EXISTS``. #447
82
+ - Inside a transaction or pipeline, ``HashKey#hsetnx``, ``ListKey#insert``, ``JsonStringKey#setnx`` and ``JsonStringKey#value`` with a default send ``EVAL`` and need scripting permission (ACL ``@scripting`` or ``+eval``). Without it they raise ``Redis::PermissionError`` (``NOPERM``) and a transaction applies none of the block's commands. Outside a block they send the plain command as before. #447
83
+ - Schema validation (``Familia::SchemaRegistry.validate`` and ``.validate!``, the ``schema_validation`` feature's instance methods, and ``Familia::Migration::Base#validate_schema`` and ``#validate_schema!``) raises ``Familia::SchemaValidatorLoadError`` when ``json_schemer`` is installed but one of its own requires fails. It previously warned ``json_schemer gem not installed`` and disabled validation, so every record passed. When ``json_schemer`` itself cannot be loaded, validation still warns and is disabled. See ``docs/migrating/runtime-dependencies.md``. #448
84
+ - When ``json_schemer`` is installed but one of its own requires fails, ``Familia::Migration::Model#migrate`` raises ``Familia::SchemaValidatorLoadError`` at the first record it validates, through ``validate_before_transform?``, ``validate_after_transform?`` or a ``#validate_schema`` call in ``#process_record``, in dry runs and actual runs. ``Familia::Migration::Pipeline`` does not run the validation hooks; it raises only when ``#should_process?``, ``#build_update_fields`` or ``#execute_update`` calls ``#validate_schema``, and only in an actual run. Where ``#migrate`` raises it, ``Familia::Migration::Base.run`` and ``.cli_run`` let it propagate, and ``Familia::Migration::Runner`` records the migration as ``:failed`` and not applied. #448
85
+ - ``Familia::Migration::Model#migrate`` in interactive mode (``@interactive = true``) raises ``Familia::Migration::Errors::PreconditionFailed`` instead of ``LoadError`` when ``pry-byebug`` is missing from the application bundle or fails to load, so ``Familia::Migration::Runner`` records the migration as ``:failed``. ``rake familia:migrate:run`` and ``familia:migrate:dry_run`` then print the failure and exit 0, as for any other failed migration; the uncaught ``LoadError`` used to end them with exit status 1. ``familia`` does not depend on ``pry-byebug``. See ``docs/migrating/runtime-dependencies.md``. #448
86
+ - ``Familia.join``, and so ``Familia.dbkey``, ``Horreum.dbkey``, ``Horreum#dbkey`` and the ``dbkey`` of a DataType with a parent, returns its result in the encoding ``Familia.normalize_key_encoding`` gives it. Key bytes are unchanged; the encoding changes for an identifier that is UTF-8 with invalid bytes (now BINARY), BINARY with valid UTF-8 bytes (now UTF-8), or in another encoding with non-ASCII bytes, such as ISO-8859-1 (now BINARY or UTF-8 by its bytes), and for an ASCII-only result whose first part is a Symbol, an Integer or a String not labelled UTF-8, such as a key under a class's default Symbol prefix (now UTF-8). A DataType's ``dbkey:`` option, a standalone DataType's key and the patterns of ``dbkey_pattern``, ``scan_pattern`` and ``find_keys`` keep the encoding they are given, and ``dbkey_pattern`` raises ``Encoding::CompatibilityError`` for a BINARY or ISO-8859-1 identifier glob with non-ASCII bytes under a non-ASCII prefix. See ``docs/migrating/dbkey-encoding.md``. #453
87
+ - ``DatabaseLogger.command_string``, the text of captured commands and of unstructured log lines, always returns UTF-8 or ASCII-only text. When the joined command is not valid UTF-8, or its arguments' encodings are incompatible, each argument is read as UTF-8 with every invalid byte written as ``\xNN``, so a BINARY member ``"bad\xFF"`` reads ``bad\xFF`` with a literal backslash and a BINARY argument with valid UTF-8 bytes reads as that text. Structured log lines are unchanged. #453
88
+ - The deserialization, ``find_by_id``, ``commit_fields``, ``refresh!``, ``update_expiration``, ``serialize_value`` and ``HashKey`` write log lines, the warning for an indexed field value longer than 256 characters, the structured context of ``Familia.debug``, ``Familia.info`` and ``Familia.error``, ``Familia.trace`` output, and the messages of the ``TypeError`` a ``HashKey`` store raises, ``WatchAbortError`` and the dirty-write warning and error show a key or value whose bytes are not valid UTF-8 with every invalid byte written as ``\xNN``. Other log lines that name a key, such as those of the atomic index rebuild and of ``repair_participations!``, write its bytes as they are. The legacy JSON-encoded identifier warning writes ``->`` instead of ``→``. #453
89
+ - The ``participations`` reverse-index entry for a target whose identifier is in an encoding other than UTF-8 or BINARY and has non-ASCII bytes, such as ISO-8859-1 ``"caf\xE9"``, holds the collection key's bytes. 2.13.0 stored the key transcoded to UTF-8, which is not the collection's key. ``remove_from_<target>_<collection>`` does not remove an entry written by 2.13.0, and adding the membership again leaves both. ``participating_ids_for_target`` returns the identifier's bytes, as UTF-8 when they are valid UTF-8 and as BINARY otherwise (``"caf\xE9".b``), which finds the target; 2.13.0 returned one that found no record. ``current_participations`` and ``relationship_status`` leave the membership out and return the participant's other memberships, as 2.13.0 did. #453
90
+ - Familia passes a new argument to these methods, so an application override that keeps the 2.13.0 parameters raises ``ArgumentError`` when Familia calls it: the Horreum class method ``instantiate_from_hash(obj_hash, dbkey: nil)`` (public, ``@api private``), which ``find_by_dbkey`` (and so ``find_by_id`` and class-level ``destroy!``), ``load_multi`` and ``load_multi_by_keys`` call with ``dbkey:``; ``read_instance_index_scopes`` (public, from the ``relationships`` feature), which takes an optional ``operation:`` that ``Horreum#destroy!`` passes and ``save`` and ``save_if_not_exists!`` do not; the private class method ``scan_matching_keys(pattern, client, batch_size: 100, type: nil)``, which ``audit_participations``, and so ``health_check``, ``repair_all!`` and ``repair_participations!`` without arguments, call with ``type:`` for each ``participates_in`` collection; the private ``Horreum#log_deserialization_issue(val, field_name, dbkey = nil)``, which ``deserialize_value`` calls with three arguments for every value that is not JSON; and ``Familia::IndexedFieldFastWriteError#initialize(field, index_name, offenders: nil, inside_block: false)``, which a ``field!`` fast writer calls with ``inside_block: true`` inside a transaction or pipeline. They took ``(obj_hash)``, no arguments, ``(pattern, client, batch_size: 100)``, ``(val, field_name)`` and ``(field, index_name, offenders: nil)``. ``Familia::AtomicOperations.assert_rebuild_context!(redis)`` (public) requires ``redis``, which ``with_rebuild`` passes. It took no arguments. A call without ``redis``, or an override without the parameter, raises ``ArgumentError``. #445 #446 #447
91
+
92
+ Removed
93
+ -------
94
+
95
+ - ``familia.gemspec`` no longer declares ``connection_pool``, and ``require 'familia'`` no longer requires it itself. ``familia`` calls no ``connection_pool`` API, and it no longer limits the ``connection_pool`` version an application resolves (previously ``>= 2.4, < 4.0``). Applications that pass a ``ConnectionPool`` to ``Familia.connection_provider`` should list ``connection_pool`` in their Gemfile and require it. See ``docs/migrating/runtime-dependencies.md``. #448
96
+
97
+ Fixed
98
+ -----
99
+
100
+ - ``require 'familia'`` no longer fails with ``cannot load such file -- base64 (LoadError)`` on Ruby 3.4+ in an application bundle without ``base64``. #448
101
+ - On Ruby 3.4+, ``require 'familia'`` no longer fails with ``cannot load such file -- bigdecimal (LoadError)`` when the bundle resolves ``oj`` ``3.16.0`` or ``3.16.1`` and nothing in it declares ``bigdecimal`` (for example with ``json_schemer`` ``2.0.0`` to ``2.1.1``). The ``json_schemer`` floor fixes it: ``json_schemer`` ``2.2.0`` to ``2.5.0`` declare ``bigdecimal``, so the bundle contains it. The ``oj`` floor also excludes ``3.16.0`` and ``3.16.1``, which require ``bigdecimal`` without declaring it. #448
102
+ - ``Familia::Encryption.benchmark`` no longer requires ``benchmark``, a bundled gem from Ruby 4.0, so it no longer raises ``cannot load such file -- benchmark (LoadError)`` under Bundler on Ruby 4.0 when the application bundle lacks ``benchmark``. #448
103
+ - A related field declared after an instance of the class already existed (``participates_in`` finishing at load while an early request holds an instance, or any late ``Klass.list :x``) is now built by that instance's accessor on first access instead of raising ``RuntimeError: <Class>#<field> is nil. Did you override initialize without calling super?``, so ``update_expiration``, ``persist!``, ``ttl_report`` and instance ``destroy!`` work on instances created before the declaration. #430
104
+ - Such a late related field is built once per instance, from the current definition, under the class's build lock (concurrent first access constructs it once), and that first build freezes the definition like any other materialization. Already-built fields are never rebuilt. An accessor inherited from an ancestor that declared the field after the subclass was defined has no definition in the subclass registry and raises ``Familia::HorreumError`` naming that gap. #430
105
+ - A related-field declaration (instance-level or ``class_*``) now defines the accessors before it inserts the definition into the registry. ``update_expiration``, ``persist!``, ``ttl_report`` and instance ``destroy!`` snapshot the registry and call ``send(name)``; a snapshot taken between the insert and the accessor definition could call a method that did not exist yet and raise ``NoMethodError``. #430
106
+ - A record whose identifier field holds an Integer can be saved more than once when its class declares a ``unique_index``. Unless the application declared the index hashkey itself, before ``unique_index``, the generated ``guard_unique_<index>!`` raised ``Familia::RecordExistsError`` naming the record itself as the owner on every save after the first, and the instance-scoped guard behind ``add_to_<scope>_<index>`` refused a second add of the same record. Both guards now compare the identifier's string form. #444
107
+ - ``Horreum#refresh!`` and ``Horreum#refresh`` leave the object as it was when a field setter raises on a stored value, for example ``Familia::EncryptionError`` for an encrypted field whose stored envelope names an algorithm that is not available. Field values, transient fields and dirty tracking are restored before the error propagates. They left the fields before the failing one assigned, transient fields reset, and the assigned fields marked dirty. #445
108
+ - ``Horreum#refresh!`` and ``Horreum#refresh`` no longer send ``HDEL`` to the ``objid_lookup`` or ``extid_lookup`` hash when the in-memory ``objid`` or ``extid`` differs from the stored one. This removed another record's entry for an object built from that record's attributes, and the object's own entry when ``objid`` or ``extid`` is the identifier field. #445
109
+ - ``HashKey#refresh!`` and ``HashKey#refresh`` no longer write to the hash; they send only ``HGETALL``. They sent ``EXISTS``, ``HGETALL`` and then ``HMSET`` with every field ``HGETALL`` returned, each value deserialized and serialized again, which rewrote every value that round trip changed: in a ``hashkey`` with no ``reference:`` or ``class:`` option, ``plainstring`` became ``"plainstring"``, ``1.50`` became ``1.5`` and ``{"b": 1}`` became ``{"b":1}``; with ``class:``, a value for which the class's ``from_json`` raised or returned ``nil`` became ``null``. A field changed or deleted between the ``HGETALL`` and the ``HMSET`` was set back to the value read. #445
110
+ - ``Horreum#naive_refresh`` no longer raises ``Familia::NoIdentifier`` on an object whose identifier is not set yet, and no longer sends ``HDEL`` to ``objid_lookup`` on an object with no ``objid`` yet whose Proc identifier reads ``objid``. #445
111
+ - ``find_by_id``, ``find_by_dbkey``, ``load_multi``, ``load_multi_by_keys`` and ``Horreum#naive_refresh`` no longer generate an ``objid`` or send ``HDEL`` to ``objid_lookup`` or ``extid_lookup`` when a stored value is not JSON and the identifier reads ``objid`` or ``extid``. #445
112
+ - The error log entry for a stored value that is not JSON no longer computes the identifier. It names the key the value was read from (``find_by_id``, ``find_by_dbkey``, ``load_multi``, ``load_multi_by_keys`` and ``storage_inspect`` pass it), otherwise the key built from a Symbol or String identifier field's current value, or ``no dbkey``. It named a key built from the generated ``objid``. #445
113
+ - With a glob character in the class prefix, delimiter or suffix, ``all``, ``scan_pattern``, ``keys_count``, ``scan_count``, ``keys_any?``, ``scan_any?``, ``scan_keys``, ``find_keys``, ``audit_instances``, ``rebuild_instances``, the unique-index SCAN rebuild fallback and model migrations no longer include another class's keys or miss the class's own keys. #446
114
+ - A ``multi_index`` rebuild no longer deletes these keys of other records of the same class: the hash and fields of a record whose identifier equals a class-level index name, the set fields of a scope record whose identifier is ``<scope>:<index>``, and the buckets of a scope whose identifier starts with ``<scope>:<index>:``. Such a key belongs to the existing record with the longest identifier. ``audit_multi_indexes`` reads it for that record. A key that ends in an instance-scoped index name, such as ``company:c-1:dept_index:x:dept_index``, is a key of record ``c-1:dept_index:x`` only when the scope class declares a set field of that name; otherwise it is the bucket of ``x:dept_index`` in ``c-1``. #446
115
+ - ``audit_multi_indexes``, ``repair_multi_indexes!``, ``repair_all!`` and ``health_check`` no longer raise ``Redis::WrongTypeError`` when a key under a multi-index bucket prefix is not a set, such as the hash of a record whose identifier equals a class-level index name. #446
116
+ - A ``multi_index`` bucket whose field value equals the class suffix, such as ``customer:role_index:object``, is no longer taken for an object: ``audit_instances`` no longer reports it as missing, ``scan_count`` and ``keys_count`` no longer count it, and ``all``, ``health_check``, ``repair_instances!`` and ``rebuild_instances`` no longer raise ``Redis::WrongTypeError``. A key of another type at an object key, such as a string at ``customer:x:object``, is no longer reported as missing by ``audit_instances`` and ``health_check``, and ``repair_instances!`` and ``repair_all!`` no longer raise ``Redis::WrongTypeError`` on it. #446
117
+ - ``audit_participations`` no longer raises ``Redis::WrongTypeError`` on a set bucket named like a sorted set or list collection. #446
118
+ - ``extract_identifier_from_key`` compares and slices the key as bytes and returns the identifier in the key's encoding. It returns ``nil`` for a key with no identifier segment between the prefix and the suffix, such as ``customer:object``; it returned ``""``. ``customer::object`` still gives ``""``. ``audit_instances``, ``audit_related_fields``, ``audit_participations``, ``audit_unique_indexes``, ``audit_multi_indexes``, ``health_check`` and ``repair_all!`` of a class whose prefix holds non-ASCII characters no longer raise ``Encoding::CompatibilityError`` for a record whose identifier is not valid UTF-8, including one that is a member of a participation collection. #446 #453
119
+ - ``audit_multi_indexes``, ``repair_multi_indexes!``, ``health_check`` and ``repair_all!`` of a class with an instance-scoped ``multi_index`` no longer raise ``Encoding::CompatibilityError`` when a scope identifier labelled UTF-8 or BINARY is not valid UTF-8 and the scope class prefix holds non-ASCII characters, or the field value does and the class declares ``participates_in`` of the scope class. Without that declaration, for a scope identifier given as UTF-8 with invalid bytes and a non-ASCII field value, the audit no longer reports the bucket's records as stale (``:value_mismatch``) and ``repair_multi_indexes!`` no longer rebuilds the scope on every run. The audit matches such a bucket to its scope and records, including the bucket of a scope whose identifier contains the delimiter and the index name. With the declaration and a scope identifier in another encoding with non-ASCII bytes, such as ISO-8859-1 ``"caf\xE9"``, the audit still reports the scope's buckets as ``:field_value_unheld`` orphans and ``repair_multi_indexes!`` rebuilds them on every run. #446 #453
120
+ - With a blank field value, a ``multi_index`` no longer reads or changes a related field whose name equals the index name, such as a ``class_set :roles`` beside ``multi_index :role, :roles`` or a ``set :tags`` of the scope class beside ``multi_index :tag, :tags, within: Company``. The instance-scoped ``remove_from_*`` and ``update_in_*`` and the class-level ``update_in_class_*`` removed the object from it, the class-level ``find_all_by_*`` returned its members, and ``indexed_in?`` and ``current_indexings`` read it. ``IndexDescriptor#each_record`` raises ``ArgumentError`` for a blank ``value:`` of a ``multi_index``, as it does for ``nil``; it iterated the related field's members. #446
121
+ - ``audit_multi_indexes`` no longer reports a class-level bucket of the scope class whose field value contains ``:<index>:`` as an orphaned bucket of an instance-scoped index, unless the hash key of the scope record the key names holds a hash. #446
122
+ - ``SortedSet#diff(withscores: true)`` returns ``[member, score]`` pairs. It raised ``ArgumentError: unknown keyword: :withscores``. #447
123
+ - ``HashKey#increment``, ``#decrement`` and ``#incrbyfloat`` return the command's ``Redis::Future`` inside ``transaction``, ``atomic_write`` and ``pipelined`` blocks. They raised ``NoMethodError`` there, which aborted the whole block. #447
124
+ - ``HashKey#randfield(count, withvalues: true)`` returns ``[field, value]`` pairs. It returned ``[[[field, raw_value], nil]]`` when one pair came back and raised ``ArgumentError`` for more. #447
125
+ - ``HashKey#hsetnx`` outside a block sends ``EXPIRE`` with the key's ``default_expiration`` after it sets a new field, replacing any TTL the key had, and sends none when the field exists or the ``default_expiration`` is 0. A ``class_hashkey`` declared without ``default_expiration:`` or ``no_expiration: true`` takes its class's positive ``default_expiration``, also without ``feature :expiration``, so one written only through ``hsetnx`` now expires, with all its fields, once ``hsetnx`` sets no new field within that time; saving a record does not refresh class-level keys. ``hsetnx`` never refreshed the TTL, so such a hash had none. #447
126
+ - ``Lock#release`` inside a transaction or pipeline returns the EVAL ``Redis::Future``, which resolves to ``true`` or ``false`` after the block, and ``MultiResult#results`` carries the same Boolean. It returned ``false`` even when the queued script released the lock. The Future is truthy whatever the outcome, so read its value after the block. A queued release does not stop the block's other commands when the lock was lost before EXEC. #447
127
+ - ``JsonStringKey#empty?`` on a key declared with ``default:`` reads with GET only, inside and outside a block; its return value outside a block is unchanged. It wrote the default to a missing key (SETNX) and refreshed the TTL. #447
128
+ - ``Familia::IndexedFieldFastWriteError`` raised by a ``field!`` fast writer inside a transaction or pipeline says to write before or after the block, and the new ``#inside_block`` reader is ``true`` for that case. Its message recommended ``multi_field_update`` or ``save``, which raise ``Familia::OperationModeError`` there. #447
129
+ - ``Familia.join`` no longer raises ``Encoding::CompatibilityError`` when its parts carry non-ASCII bytes in incompatible encodings, such as a non-ASCII class ``prefix`` with a BINARY identifier. The parts are joined as bytes, so ``save``, ``exists?``, ``load`` and ``rebuild_instances`` work for such a record. #453
130
+ - ``save`` and ``add_to_<scope>_<index>`` no longer raise ``Familia::RecordExistsError`` from ``guard_unique_<index>!`` or ``guard_unique_<scope>_<index>!`` against the record's own index entry for a loaded record whose identifier is not valid UTF-8, or a record whose identifier was given as UTF-8 with invalid bytes, such as ``"bad\xFF"``. The guards compare identifiers by their bytes. As 2.13.0 did for ``'q'`` against ``'"q"'``, they read a stored owner wrapped in one pair of double quotes as the identifier inside, so a record given ``"\xFF"`` now passes them and takes the entry of a record whose identifier is ``"\"\xFF\""``. Do not use identifiers that start and end with a double quote with unique indexes. #453
131
+ - An instance-scoped ``multi_index`` with a non-ASCII field value no longer raises ``Encoding::CompatibilityError`` from ``add_to_<scope>_<index>``, ``update_in_<scope>_<index>`` or ``remove_from_<scope>_<index>`` given a scope whose identifier is not labelled UTF-8 and holds non-ASCII bytes (BINARY, such as ``'zoé'.b`` or ``"bin\xFE".b``, or ISO-8859-1), or from ``save`` or ``destroy!`` of a record indexed in a scope whose identifier is not valid UTF-8. ``destroy!`` removes the record's index entries. #453
132
+ - A record whose indexed field value is not valid UTF-8, such as a value given as BINARY ``"r\xFEle".b``, which loads back as UTF-8 with the same bytes, no longer makes ``save`` and ``destroy!`` of the loaded record with a ``multi_index`` on that field, or ``audit_multi_indexes``, ``audit_unique_indexes``, ``health_check`` and ``repair_all!`` of its class, raise ``Encoding::CompatibilityError``. ``save`` and ``find_all_by_<field>`` no longer raise it for a ``multi_index`` value longer than 256 characters given as BINARY with non-ASCII bytes under a class whose name holds non-ASCII text. #453
133
+ - ``current_participations``, ``relationship_status``, ``participating_ids_for_target``, ``participating_in_target?`` given collection names, and the generated ``<target>_ids``, ``<target>_instances`` and ``<target>_count`` no longer raise ``ArgumentError`` (invalid byte sequence in UTF-8) for a participant whose ``participations`` set holds an entry for a target identifier that is not valid UTF-8, given as BINARY (``"bin\xFE".b``) or as UTF-8. They read each entry by its bytes. ``current_participations`` returns the participant's other memberships and that membership, with ``target_id`` the identifier's bytes as BINARY. #453
134
+ - Reading a collection with non-ASCII members (``members``, ``each``, ``revrange``), ``rebuild_<index>``, ``commit_fields``, ``HashKey#refresh!`` and reading a legacy JSON-encoded entry of a scope's ``unique_index`` or ``multi_index`` (``[]``, ``get``, ``members``, ``find_by_<field>``, ``find_all_by_<field>``) no longer raise ``Encoding::CompatibilityError`` for a record or scope given a BINARY identifier that is not valid UTF-8. Nor do ``save``, ``find_by_id``, ``refresh!``, ``commit_fields``, ``save_fields``, the fast writers and adding the record to a collection, for such a record of a class whose name holds non-ASCII text. With debug on, neither does ``find_by_id`` given such an identifier for a record with a non-ASCII field value, nor a collection write of a BINARY value with non-ASCII bytes. #453
135
+ - ``atomic_write`` given ``watch_keys:`` that include a BINARY key with non-ASCII bytes, such as the key of a record given ``"bin\xFE".b``, and a non-ASCII UTF-8 key retries a WATCH conflict and raises ``WatchAbortError`` when the retries run out, instead of raising ``Encoding::CompatibilityError`` on the first conflict. #453
136
+ - ``DatabaseLogger`` no longer raises ``Encoding::CompatibilityError`` after a command whose arguments are in incompatible encodings, or, with unstructured logging, after a command with a non-ASCII BINARY argument. The command had run, but the caller received the error. #453
137
+
138
+ Security
139
+ --------
140
+
141
+ - The generated ``rebuild_<index>`` of a ``multi_index`` put the scope instance's identifier (the class prefix, for a class-level index) into its SCAN pattern unescaped and deleted every match. Rebuilding the index of a scope whose identifier holds a glob character deleted other scopes' index sets: rebuilding ``c-*`` deleted those of ``c-1`` and ``c-2``. The literal part of the pattern is now escaped, and a matched key is deleted only if it starts with that literal prefix, holds a set and is not a set field or ``multi_index`` bucket of an existing record of the same class with a longer identifier. A record exists when its hash key holds a hash. A matched key with more than ``MAX_CANDIDATE_SPLITS`` candidate owner records stops the rebuild before it deletes anything. #446
142
+ - ``repair_participations!`` removed members from another class's collection of the same name when the target class prefix held a glob character. ``audit_participations`` now escapes the pattern and keeps only keys with the target's literal prefix and suffix. #446
143
+ - ``repair_related_fields!`` deleted a ``multi_index`` bucket whose field value equaled the name of a related field of the class, such as ``customer:role_index:notes`` for role ``notes`` and a ``notes`` field, or ``company:c-1:dept_index:tags`` for dept ``tags`` and a ``tags`` field of the scope class. ``repair_participations!`` removed every member of a bucket whose field value equaled a collection name. ``audit_related_fields`` and ``audit_participations`` now skip a key that is a bucket of a class-level ``multi_index`` of the class, or of an instance-scoped ``multi_index`` whose scope record exists, unless the hash key of the record the key names holds a hash. The bucket of field value ``object`` is a set at such a hash key and does not count as that record. #446
144
+
145
+ Documentation
146
+ -------------
147
+
148
+ - Added ``docs/migrating/refresh.md`` covering the ``Horreum#refresh!``, ``Horreum#refresh``, ``HashKey#refresh!`` and ``HashKey#refresh`` changes. #445
149
+ - Corrected the ``docs/overview.md`` transient-field examples. They called ``reload``, which does not exist, instead of ``refresh!``. The ``LoginAttempt`` example also used ``redacted_field`` and ``RedactedString#reveal``, which do not exist, declared no identifier, and read a transient value after ``refresh!``, which resets it to nil. The ``SecureModel`` example passed two names to ``field``, ``encrypted_field`` and ``transient_field``, which take one, declared no identifier or encryption keys, called ``create`` instead of ``create!``, and called ``ConcealedString#reveal`` without a block. #445
150
+ - Added ``docs/migrating/key-patterns-and-index-rebuilds.md``, the migration guide for the changes in #446, and documented ``multi_index`` rebuilds and ``Familia::IndexBucketConflictError`` in the indexing guide and the index rebuilding reference. #446
151
+ - ``docs/reference/transaction_safety.md`` rule 4 describes how each method handles command replies inside transactions and pipelines. #447
152
+ - ``docs/migrating/transaction-replies.md`` lists the calls inside a block that behaved differently before and need a code change: partial writes and unstaging that committed with an outer transaction, ``extend_expiration``, the ``Lock`` ownership checks and ``release``, ``current_indexings``, ``Migration::Registry`` without ``redis:``, ``as_json`` on a scalar DataType, the instance-scoped ``add_to_*`` with no field value inside a pipeline, the TTL refresh of ``HashKey#hsetnx``, ``ListKey#insert``, ``JsonStringKey#setnx`` and ``JsonStringKey#value``, and the TTL ``HashKey#hsetnx`` now sets outside a block. #447
153
+ - Added ``docs/migrating/runtime-dependencies.md``, which lists the application changes needed for ``familia``'s dependency changes since ``2.13.0``: ``base64``, ``connection_pool``, the ``oj``, ``json_schemer`` and ``redis`` floors, interactive migrations and ``Familia::SchemaValidatorLoadError``. #447 #448
154
+ - The schema validation guide no longer tells applications to add ``json_schemer`` to their Gemfile; ``familia`` declares it. #448
155
+ - Added ``docs/migrating/dbkey-encoding.md``, which lists the encoding of a key in 2.13.0 and in this release, the log text changes, the participations reverse-index entries and audits for ISO-8859-1 identifiers, what to change, and what 2.13.0 cannot read or delete after a rollback. #453
156
+ - Documented connection scopes in ``docs/reference/api-technical.md`` (``Connection Scopes``, with Rack middleware and job examples) and ``docs/guides/index.md``, and corrected the resolution order listed under ``Connection Chain Debugging``. #456
157
+
158
+ AI Assistance
159
+ -------------
160
+
161
+ - The changes were developed with AI assistance. #443 #444 #445 #446 #447 #448 #453 #458
162
+
163
+ .. _changelog-2.13.0:
164
+
165
+ 2.13.0 — 2026-09-17
166
+ ===================
167
+
168
+ Added
169
+ -----
170
+
171
+ - Added ``Familia::AtomicOperations.with_rebuild(final_key, redis, ttl: DEFAULT_REBUILD_TTL, preserve_for: DEFAULT_PRESERVE_TTL)``, which yields ``(temp_key, touch)`` and owns the rebuild lock, the bounded-TTL temporary key, the atomic swap, and cleanup. Raises ``Familia::RebuildInProgressError`` when the lock is already held.
172
+ - Added ``Familia::AtomicOperations.sweep_orphaned_temp_keys(redis, pattern: '*:rebuild:*', older_than: DEFAULT_PRESERVE_TTL, dry_run: false)``, which deletes temporary rebuild keys that have no TTL and are older than the window, skipping any index with a live ``:rebuild-lock``. Run it only once every process is on this version.
173
+ - Added ``Familia::RebuildInProgressError`` (a ``Familia::PersistenceError`` with ``#key``) and its subclass ``Familia::RebuildLockLostError``.
174
+ - Added ``Familia::AtomicOperations::DEFAULT_REBUILD_TTL`` (300) and ``DEFAULT_PRESERVE_TTL`` (3600).
175
+ - ``Familia::VerifiableIdentifier.secret_key`` now reads ``IDENTIFIER_SECRET`` when ``VERIFIABLE_ID_HMAC_SECRET`` is unset or blank. ``VERIFIABLE_ID_HMAC_SECRET`` takes precedence when nonblank, and ``KeyError`` is still raised on first use when neither is set. #423
176
+ - Added ``Horreum.configure_related_field(name, **opts)`` to merge options into a declared related-field definition at boot, with an optional ``scope:`` (``:instance`` or ``:class``). Options are validated with the DataType's own rules; an unknown field name raises ``ArgumentError``. #428
177
+ - Related-field definitions now freeze at first use (the first instance for instance-level fields, the first accessor call for class-level fields). After that, ``configure_related_field`` raises ``Familia::RelatedFieldFrozenError`` and mutating ``Klass.related_fields[:x].opts`` raises ``FrozenError``. #428
178
+
179
+ Changed
180
+ -------
181
+
182
+ - ``Familia::FieldType#deserialize(value, record)`` is now called on the storage-to-object path (``load``, ``find_by_id``, ``refresh!``, ``naive_refresh``) for every persistent field. Custom field types that override it now see stored values pass through it. #405
183
+ - ``Model.new(secret: envelope_json)`` now encrypts raw envelope JSON as plaintext. Rehydrate from a raw storage hash with ``naive_refresh`` instead. #405
184
+ - ``rebuild_instances`` and the ``rebuild_via_instances``, ``rebuild_via_participation``, and ``rebuild_via_scan`` strategies run under ``with_rebuild``. A rebuild of an index that is already being rebuilt raises ``Familia::RebuildInProgressError`` immediately.
185
+ - ``AtomicOperations.atomic_swap`` now issues ``RENAME`` and ``PERSIST`` from one Lua script and accepts ``preserve_for:``, ``lock: { key:, token: }`` (a blank token raises ``ArgumentError``), and ``populated:``.
186
+ - ``AtomicOperations.with_rebuild`` raises ``Familia::OperationModeError`` inside a ``Familia.transaction`` or pipeline.
187
+ - A blank ``VERIFIABLE_ID_HMAC_SECRET`` no longer raises on its own; it falls through to ``IDENTIFIER_SECRET``. See ``docs/migrating/identifier-secret.md``. #423
188
+ - Re-declaring a related field whose definition has already materialized raises ``Familia::RelatedFieldFrozenError`` instead of silently replacing the definition. Re-declaring before first use still replaces it. #428
189
+ - Class-level related fields validate their options at the declaration line, as instance-level fields now do. #428
190
+ - Declaration, re-declaration, ``configure_related_field`` and the freeze serialize on the class's ``related_fields_mutex``, which is created when the class is defined. DataType construction runs outside that mutex, so a custom ``init`` that reads another collection of the same class no longer raises ``ThreadError``. #428
191
+ - Class-level collections are built lazily on the first accessor call, single-flight under a per-class build lock, and cached in ``Klass.class_related_field_cache`` instead of ``@<name>`` on the class. #428
192
+ - The first ``Klass.new`` and the related-field cascades (``update_expiration``, ``persist!``, ``ttl_report``, ``delete_related_fields!``, class-level ``destroy!``, ``guard_atomic_write_database!``, the unique-index rebuild fallback scan, and ``Horreum.inherited``) iterate registry snapshots (``related_fields_snapshot``, ``class_related_fields_snapshot``), so a field declared concurrently no longer raises ``RuntimeError: can't add a new key into hash during iteration``. #428
193
+ - ``initialize_relatives`` no longer writes ``parent`` into the shared definition Hash. #428
194
+ - Subclasses receive their own copies of related-field definitions, and a subclass that inherits a class-level field without re-declaring it gets a working accessor keyed under the subclass. #428
195
+
196
+ Fixed
197
+ -----
198
+
199
+ - ``Model.find_by_id``, ``find_by_dbkey``, and ``load_multi`` no longer raise ``Familia::NoIdentifier`` when a stored field value is not valid JSON. The record loads with the raw string and logs ``Legacy plain string in ...``.
200
+ - ``EncryptedData.valid?`` no longer echoes the candidate value into the debug log, and ``EncryptedData.validate!`` and ``Encryption::Manager#decrypt`` name only the JSON parser's error class in their ``EncryptionError``. #406
201
+ - Deserialization failure logs and the ``Familia::Instrumentation.notify_error`` payload no longer carry the stored value. ``value_length`` is always reported; a bounded ``value_preview`` is logged only when ``Familia.debug?`` is on and is never sent to ``notify_error``. #407
202
+ - ``ExternalIdentifier.extid?`` rejects uppercase IDs, and external identifier format declarations require one ``%{id}`` placeholder. #400 #401
203
+ - ``Familia::ThreadSafety::InstrumentedMutex#synchronize`` now always acquires the underlying mutex. It was a no-op unless the thread-safety monitor was enabled, so every class-level registry guard built on it did not lock. #428
204
+ - Instance-scoped unique-index rebuilds no longer mistake the backing index for a membership collection when no participation collection is declared.
205
+
206
+ Security
207
+ --------
208
+
209
+ - Encrypted field setters no longer duck-type the assigned value. Only a ``Familia::Encryption::StoredEnvelope``, applied by ``EncryptedFieldType#deserialize`` on hydration, is stored verbatim; every other assignment is encrypted. #405
210
+ - The envelope ``encoding`` field is validated against ``Familia::Encryption::ENVELOPE_ENCODINGS`` before use. Anything else raises ``EncryptionError: Unsupported encoding`` from ``validate_decryptable!``, ``Manager#decrypt``, and ``ConcealedString`` wrapping, and ``decryptable?`` returns false. Envelopes without the field still decrypt as UTF-8. Binding the field into the AAD so that a valid-but-wrong name is also rejected remains open; it needs a new envelope version. #408
211
+ - Assigning plaintext to an encrypted field raises ``Familia::NoIdentifier`` when the record has no identifier, so ciphertext cannot become undecryptable once an identifier is assigned.
212
+ - ``rebuild_via_scan`` propagates batch failures and preserves the live index instead of replacing it with incomplete contents.
213
+ - Index rebuilds hold a per-index ``<final_key>:rebuild-lock`` refreshed by compare-and-extend and raise ``Familia::RebuildLockLostError`` when another rebuild takes it over. ``AtomicOperations.build_temp_key`` appends a 64-bit random nonce (``<key>:rebuild:<timestamp>:<16 hex>``), temporary keys carry a refreshed TTL, and a failed swap retains the key for ``preserve_for`` (default 3600 seconds).
214
+ - ``AtomicOperations.atomic_swap`` fails closed: one Lua script verifies the lock token and performs the ``RENAME`` (or the empty-rebuild ``DEL``), a lost lock raises ``Familia::RebuildLockLostError``, and a populated temporary key that is gone at swap time raises ``Familia::PersistenceError``.
215
+
216
+ Documentation
217
+ -------------
218
+
219
+ - Linked security audit status entries to their July 26 audit findings.
220
+
221
+ AI Assistance
222
+ -------------
223
+
224
+ - Several of the fixes, the rebuild lock design, and their regression tests were developed with AI assistance.
225
+
10
226
  .. _changelog-2.12.0:
11
227
 
12
228
  2.12.0 — 2026-08-03
data/Gemfile CHANGED
@@ -5,7 +5,13 @@ source 'https://rubygems.org'
5
5
  gemspec
6
6
 
7
7
  group :test do
8
+ # The tryouts build fixture keys and inspect envelopes with Base64. Declare
9
+ # it here rather than relying on another gem in the bundle to bring it in.
10
+ gem 'base64', require: false
8
11
  gem 'concurrent-ruby', '~> 1.3.8', require: false
12
+ # The pooling tryouts build ConnectionPool::Wrapper providers, as an
13
+ # application would. familia itself does not use connection_pool.
14
+ gem 'connection_pool', require: false
9
15
  gem 'ruby-prof'
10
16
  gem 'stackprof'
11
17
  gem 'timecop', require: false
@@ -20,13 +26,12 @@ group :development, :test do
20
26
  # Ruby 3.2 (our gemspec's required_ruby_version floor). 1.4.0 only adds
21
27
  # Config#to_data, which we don't use (we don't use Dry::Configurable at all),
22
28
  # so cap below 1.4 to keep the dev bundle installable on Ruby 3.2.
23
- gem 'dry-configurable', '>= 1.3', '< 1.5', require: false
29
+ gem 'dry-configurable', '>= 1.3', '< 1.4', require: false
24
30
  gem 'irb', '~> 1.18.0', require: false
25
- gem 'json_schemer', '~> 2.0', require: false
26
31
  gem 'rake', '~> 13.0', require: false
27
32
  gem 'redcarpet', require: false
28
33
  gem 'reek', require: false
29
- gem 'rubocop', '~> 1.90.0', require: false
34
+ gem 'rubocop', '~> 1.92.1', require: false
30
35
  gem 'rubocop-performance', require: false
31
36
  gem 'rubocop-thread_safety', require: false
32
37
  gem 'ruby-lsp', require: false