concerns_on_rails 1.33.0 → 1.35.0

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +36 -23
  4. data/lib/concerns_on_rails/controllers/authorizable.rb +47 -12
  5. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +75 -12
  6. data/lib/concerns_on_rails/controllers/error_handleable.rb +7 -2
  7. data/lib/concerns_on_rails/controllers/filterable.rb +85 -3
  8. data/lib/concerns_on_rails/controllers/includable.rb +41 -6
  9. data/lib/concerns_on_rails/controllers/paginatable.rb +73 -11
  10. data/lib/concerns_on_rails/controllers/respondable.rb +6 -8
  11. data/lib/concerns_on_rails/controllers/secure_headable.rb +50 -6
  12. data/lib/concerns_on_rails/controllers/timezoneable.rb +29 -9
  13. data/lib/concerns_on_rails/encryption.rb +29 -6
  14. data/lib/concerns_on_rails/legacy_aliases.rb +15 -3
  15. data/lib/concerns_on_rails/models/addressable.rb +2 -0
  16. data/lib/concerns_on_rails/models/anonymizable.rb +7 -2
  17. data/lib/concerns_on_rails/models/auditable.rb +7 -11
  18. data/lib/concerns_on_rails/models/counter_cacheable.rb +65 -4
  19. data/lib/concerns_on_rails/models/duplicable.rb +27 -5
  20. data/lib/concerns_on_rails/models/encryptable.rb +35 -12
  21. data/lib/concerns_on_rails/models/expirable.rb +32 -12
  22. data/lib/concerns_on_rails/models/lockable.rb +24 -7
  23. data/lib/concerns_on_rails/models/monetizable.rb +6 -4
  24. data/lib/concerns_on_rails/models/normalizable.rb +26 -23
  25. data/lib/concerns_on_rails/models/publishable.rb +23 -1
  26. data/lib/concerns_on_rails/models/sanitizable.rb +24 -15
  27. data/lib/concerns_on_rails/models/searchable.rb +2 -4
  28. data/lib/concerns_on_rails/models/sequenceable.rb +1 -1
  29. data/lib/concerns_on_rails/models/sluggable.rb +16 -2
  30. data/lib/concerns_on_rails/models/soft_deletable.rb +41 -10
  31. data/lib/concerns_on_rails/models/sortable.rb +49 -6
  32. data/lib/concerns_on_rails/models/stateable.rb +19 -1
  33. data/lib/concerns_on_rails/models/storable.rb +2 -12
  34. data/lib/concerns_on_rails/models/taggable.rb +52 -9
  35. data/lib/concerns_on_rails/models/tokenizable.rb +8 -3
  36. data/lib/concerns_on_rails/support/batch_ops.rb +64 -3
  37. data/lib/concerns_on_rails/support/caller_window.rb +39 -0
  38. data/lib/concerns_on_rails/support/contextual_destroy.rb +9 -5
  39. data/lib/concerns_on_rails/support/edge_space.rb +31 -0
  40. data/lib/concerns_on_rails/support/error_envelope.rb +32 -1
  41. data/lib/concerns_on_rails/support/generated_values.rb +16 -0
  42. data/lib/concerns_on_rails/support/hooked_write.rb +29 -15
  43. data/lib/concerns_on_rails/support/money.rb +14 -1
  44. data/lib/concerns_on_rails/support/native_json.rb +31 -0
  45. data/lib/concerns_on_rails/support/numeric_operand.rb +17 -1
  46. data/lib/concerns_on_rails/support/scalar_param.rb +10 -6
  47. data/lib/concerns_on_rails/support/sequence_calculator.rb +20 -5
  48. data/lib/concerns_on_rails/support/time_value.rb +6 -0
  49. data/lib/concerns_on_rails/support/transform_backstop.rb +100 -3
  50. data/lib/concerns_on_rails/version.rb +1 -1
  51. data/lib/concerns_on_rails.rb +3 -0
  52. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0dd550c226626fbbe5d8c542b5e139296be95977722dd1e78560f81ed91c63b1
4
- data.tar.gz: f8770b8fe33e57b5323a76a469e8cd2cac66401a6e20a3b12a800b99776c80f4
3
+ metadata.gz: 0542404e12b0b684a042ebcb69ca240f9581cfd75a2399782b93b4a14433832c
4
+ data.tar.gz: b0a7d63e3c1ed59c36d29bccb238e6bf9531d4a40a0841d5544217a0c5cbc04f
5
5
  SHA512:
6
- metadata.gz: aeec3000adc45a218468a6be90ed35ed8022f9319f3c41a222dcdeb0e048eb95f634c5e9d4a4613ad0cbdde7558d9e24d94f132c6c664622fd48393e26ff78ad
7
- data.tar.gz: eca60a294934ecb578df643c92108a0b86ed62c1f0bd5f63c376ae12691d51ce3e6e7bce37438c896f9872c228de077950982a9c3ae53f674f83a4b10fa1ba22
6
+ metadata.gz: a678f1400483f94da5e10f0c24b5d9f2ce8f1c476183629fb8bf3ce96187c54a68a131650de902efd6913acf363168be22cfc9cde922de3f1c2607567f8f91d3
7
+ data.tar.gz: 1695643e945d899a7a279a803ddf0abadab8e54c9ac152af4d93a2a6002914448a2fc03e572f4bab4d3ecc38f3d98e54e990106359cafca8878d746b45b1e3d9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,334 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.35.0 (2026-11-09)
4
+
5
+ A small correctness release following the 2026-10-29 audit: it fixes the 3 findings the 1.34.0 release left open
6
+ (DATA-S2, PARAMS-S2, TRANS-S4). Two of them change behaviour. Read **Upgrading** first.
7
+
8
+ ### Upgrading
9
+
10
+ - [ ] **Paginatable, a relation you already limited or offset:** `paginated(Player.order(score: :desc).limit(10))`
11
+ now pages INSIDE that window: `X-Total-Count`, `X-Total-Pages` and `pagination_meta` describe the rows the relation
12
+ returns (`max(0, min(limit, rows - offset))`) and the pages slice them. A "top 10, 3 per page" relation has a total
13
+ of 10, pages 1-3 hold 3 rows and page 4 holds 1; later pages are empty. Before, your LIMIT/OFFSET was replaced by the
14
+ page's own and stripped from the COUNT, so the total was the whole table and page 4+ served rows beyond your window.
15
+ A relation without `limit`/`offset` is unchanged. If you relied on the old reading (a LIMIT used as "a big batch"
16
+ while paging the whole table), drop the `.limit(...)`/`.offset(...)` before `paginated`. (PARAMS-S2)
17
+ - [ ] **CursorPaginatable, a relation you already limited or offset:** `cursor_paginated` / `cursor_pagination_meta`
18
+ raise `ArgumentError` for a relation carrying `limit`/`offset`. Before, the per_page + 1 probe replaced your LIMIT
19
+ and the walk ran to the end of the table (OFFSET has no meaning under a keyset WHERE). Drop them, or page the window
20
+ with Paginatable. (PARAMS-S2)
21
+ - [ ] **Internal constants removed:** `Searchable::EDGE_SPACE` and `Taggable::EDGE_SPACE` no longer exist (they were
22
+ never public API); `Normalizable::EDGE_SPACE` stays. Use `ConcernsOnRails::Support::EdgeSpace` if you referenced
23
+ them. (TRANS-S4)
24
+
25
+ ### Fixed
26
+
27
+ - **Tokenizable / Hashable / Sequenceable / Sortable: a create halted by a later `before_create` (`throw :abort`)
28
+ inside a caller's transaction left the drawn value on the record.** `Support::GeneratedValues::Revertible` restored
29
+ the token/hash/sequence number/position only from `rolledback!`, which never runs when `save` returns false inside a
30
+ joined transaction (Rails swallows the Rollback). A retried `save` re-used a value that was never inserted; for
31
+ Sequenceable that is a number another row may have taken meanwhile, so a duplicate or `RecordNotUnique`. `Revertible`
32
+ now also restores the recorded values the moment the create callbacks halt, inside or outside a transaction.
33
+ (DATA-S2)
34
+ - **Paginatable / CursorPaginatable: a caller's LIMIT/OFFSET.** See Upgrading: Paginatable pages inside the window by
35
+ combining the windows arithmetically on the caller's own relation (no derived table, so `eager_load`/`includes`,
36
+ STI, `readonly` and `strict_loading` behave as in Rails); CursorPaginatable refuses it. (PARAMS-S2)
37
+ - **Normalizable / Searchable / Taggable / Monetizable: edge-whitespace stripping was ~1 microsecond per character.**
38
+ A long interior run of spaces made `gsub(/\A[[:space:]\0]+|[[:space:]\0]+\z/, "")` cost over 1 s of CPU per MB of
39
+ untrusted input (a 1 MB `"a" + " " * N + "b"` query parameter). Each edge is now stripped with one anchored match
40
+ through `Support::EdgeSpace`, linear and byte-identical in result. `TimeValue.names_year?` (verb time arguments such
41
+ as `expire!`/`publish_at!`) answers false for a String over 128 bytes before running its trailing-comment regex,
42
+ which `Date._parse` refuses anyway (the result was already false). (TRANS-S4)
43
+
44
+ ### Known issues (not fixed in this release)
45
+ - CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
46
+ mirrored counter/lock_version in memory.
47
+ - Aliasable: eager-loading an alias together with its source in one query loads each child twice.
48
+ - Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
49
+ cannot be `Marshal.dump`ed (`Rails.cache`).
50
+ - CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
51
+ its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
52
+ - Normalizable / Sanitizable: on an unvalidated (`update_attribute`) save that is halted and retried, with TWO
53
+ transforms on one column where the SECOND (not Normalizable) rewrites the first's output, Sanitizable's own record
54
+ holds only the first's output, so a validated retry re-sanitizes.
55
+ - Sluggable `history: true` + `scope:`: friendly_id's history finder ignores scope, so after a record moves scope its
56
+ stale history slug can still match `Model.where(scope).friendly.find(old_slug)`. This is stock friendly_id
57
+ behaviour; the gem does not change it. (GRAPH-5)
58
+
59
+ ## 1.34.0 (2026-10-29)
60
+
61
+ A correctness release: it fixes the 43 bugs proven by the 2026-10-29 audit of 1.33.0. Some fixes change behaviour,
62
+ and five configurations that used to be silently broken now raise at class load. Read **Upgrading** first.
63
+
64
+ ### Upgrading
65
+
66
+ **Class-load (boot) raises for configurations that were silently broken.**
67
+
68
+ - [ ] **CounterCacheable, composite keys:** `counter_cacheable_by` raises `ArgumentError` for a `belongs_to` with a
69
+ composite `foreign_key:` (`[:shop_id, :order_id]`), a composite association primary key or `query_constraints:`
70
+ (Rails 7.1+), as it does for a polymorphic one. It was accepted, never counted the children and made
71
+ `recount_counter_caches!` raise invalid SQL. Count on a single-column key instead. A model-level
72
+ `query_constraints` must be declared before `counter_cacheable_by`. (GRAPH-2)
73
+ - [ ] **CursorPaginatable, `predicate: :row`:** `predicate: :row` with a mixed-direction `order:` or `order_presets:`
74
+ entry raises `ArgumentError` at class load (also when a later re-declaration combines with an inherited one), and a
75
+ per-call `cursor_paginated(order:)` with mixed directions raises before page 1. This includes a nullable ordering
76
+ column, which used to fall back to the OR-expansion silently. Switch to `predicate: :auto` (it falls back to the
77
+ OR-expansion for mixed directions) or make the directions uniform. (PARAMS-4)
78
+ - [ ] **Stateable, event named `transition`:** a transition event whose (affixed) name is `transition` raises
79
+ `ArgumentError` at the macro. It generated a `transition!` that always raised from the generic
80
+ `before_/after_transition` hooks. Rename the event or use `prefix:`/`suffix:`. (STATE-6)
81
+ - [ ] **Authorizable, `require_role`:** `require_role` with an empty list (`require_role []`, `[[]]`) or a nil/blank
82
+ role (`require_role ENV["ADMIN_ROLE"]&.to_sym` unset) raises `ArgumentError` at class load; a blank role used to
83
+ admit actors whose role is `""` or nil. (HTTP-2)
84
+ - [ ] **Encryptable, per-field `key:`:** a DECLARED per-field `key:` that resolves to nil or `""` (a Proc over a
85
+ credential that is not there yet, an explicit `key: nil`, an eagerly nil `ENV["X"]` / credential) is a missing key:
86
+ it raises `MissingKeyError` naming the field on write, read and blind-index lookup (or leaves the value plaintext
87
+ under `on_missing_key: :passthrough`) instead of silently encrypting under the global key. Only an OMITTED `key:`
88
+ uses the gem-level key. `key:` must be a String or respond to `call`; `key: false`, `key: 123` or `key: :sym` raises
89
+ `ArgumentError` at the macro. Rows already written under the global key are NOT repaired by `reencrypt_all!`
90
+ (it skips per-field fields); they fail to decrypt under the field key. For each such row, decrypt the raw column
91
+ value with the global key (`ConcernsOnRails::Support::Encryptor.decrypt(raw, key: <global key material>,
92
+ salt: ConcernsOnRails.encryption.key_derivation_salt)`; read `raw` with `<field>_ciphertext`, since the model's
93
+ reader raises for such rows), then write the plaintext back through the model while the field key resolves, so it
94
+ is re-sealed under the field key.
95
+ Rows left plaintext under `:passthrough` only need a plain re-save once the key resolves. (DATA-2)
96
+
97
+ **Behaviour changes.**
98
+
99
+ - [ ] **Normalizable / Sanitizable / Taggable / Addressable:** Normalizable no longer re-normalizes a later transform's
100
+ output at `before_save`, so with Normalizable included BEFORE a Sanitizable/Taggable/Addressable rule on the same
101
+ column its rule no longer gets "the last word" (`squish` then `strip` on: :write stores `" a b"` where it stored
102
+ `"a b"`). Include Normalizable AFTER the other transform to normalize its output. (TRANS-2)
103
+ - [ ] **Taggable:** with a self-overlapping delimiter (`"--"`, `"||"`, `" | "`) a tag is trimmed of a trailing part
104
+ that is the start of the delimiter, and of a leading part that is the end of it: `"a-"` becomes `"a"` and `"-b"`
105
+ becomes `"b"` under `"--"`. Delimiters that do not overlap themselves are unaffected. Stored tags with a leading
106
+ straddling part are not migrated; `Model.normalize_tags!` repairs them. (TRANS-4)
107
+ - [ ] **Paginatable / Includable:** re-declaring `paginate_by` or `includable` in a subclass changes only what it
108
+ passes; every omitted option now keeps its inherited value (before it reset to the gem default, for example a
109
+ parent `max_per_page: 20` became 200). A subclass that relied on the reset must pass the option explicitly.
110
+ For `includable` the association list still replaces the parent's, and an inherited `default:` path the new list no
111
+ longer contains is dropped with a stderr warning at class load. (PARAMS-2)
112
+ - [ ] **Filterable, `_null`:** `?<field>_null=` and `?field[null]=` read only `true/false/1/0/t/f/yes/no`
113
+ (case-insensitive) or a real boolean; any other NON-BLANK value (`maybe`, `on`, `off`, `nope`) matches nothing
114
+ instead of being read as `IS NULL` (`off` used to be `IS NOT NULL`). A blank value is skipped as before, and
115
+ surrounding whitespace is ignored. (PARAMS-6)
116
+ - [ ] **Filterable, encrypted columns:** on an `encryptable` column equality and `in` go through the blind index and every
117
+ other operator (`not`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`) fails closed (`none`); a field
118
+ without a blind index fails closed for every operator but `null`. Add `blind_index: true` to filter an encrypted
119
+ field by equality. (PARAMS-1)
120
+ - [ ] **Filterable, `with:` lambdas:** a lambda declared with a `type:` receives no Array/Hash/Parameters value; such a
121
+ value fails the filter closed (`none`) for every `type:`. (PARAMS-S4)
122
+ - [ ] **Filterable, float columns:** a decimal literal that a double does not read back as (more digits than a Float
123
+ keeps, e.g. `0.1000000000000000000001`) equals nothing and compares as the largest double at or below it. (PARAMS-8)
124
+ - [ ] **Paginatable / CursorPaginatable, `page` / `per_page`:** read as exact whole numbers (`1e3` is 1000, `2e0` is 2);
125
+ a fractional (`2.9`), trailing-garbage (`10abc`) or unreadable value falls back to the default instead of its
126
+ leading digits, and a value beyond 100 digits falls back to the default instead of clamping. (PARAMS-7)
127
+ - [ ] **CursorPaginatable, SQLite:** a hand-made cursor whose String boundary carries a NUL byte is accepted on SQLite
128
+ (it cannot be told from a minted one) instead of answering `InvalidCursor`. (PARAMS-5)
129
+ - [ ] **Publishable:** `unpublish!` on a stored draft (blank saved column; false/NULL on a boolean) is a no-op like
130
+ `publish!`: it returns `true`, writes nothing and fires no `before/after_unpublish`. A scheduled row is still
131
+ cleared with the hooks. (STATE-5)
132
+ - [ ] **SoftDeletable:** `soft_delete!`/`restore!` decide "already done" from the SAVED stamp. Assigning the stamp
133
+ without saving no longer makes the verb return `true` while writing nothing: it writes, fires the hooks and cascades.
134
+ A no-op verb now saves OTHER pending changes (previously dropped; a validation can make it return `false`). (STATE-12)
135
+ - [ ] **Expirable:** `expire_in!(nil)` and `extend_expiry!(by: nil)` (anything but an `ActiveSupport::Duration` or a
136
+ `Numeric`) raise the concern's `ArgumentError` instead of a bare `TypeError`; an object that merely responds to
137
+ `to_i` is refused too. (STATE-10)
138
+ - [ ] **Expirable, `date` columns:** `expire_in!(15.minutes)` on a `date` column writes today's date, so it now fires the
139
+ hooks, because the record is `expired?` (unchanged for UTC process zones). (STATE-2)
140
+ - [ ] **Anonymizable:** `anonymize!` returns `false` (no `after_anonymize`) when its UPDATE reached no row, and
141
+ `anonymize_all!` no longer counts such a row. (STATE-9)
142
+ - [ ] **Lockable:** `register_failed_attempt!` on a `readonly!` or destroyed record raises before incrementing, so
143
+ nothing is written; an already-locked record still just answers the count. (DATA-4)
144
+ - [ ] **Monetizable:** the writer casts an amount the cents column cannot hold to `nil`, like other oversized input
145
+ (a 4-byte integer column overflows at about 21.4 million units). (TRANS-S3)
146
+ - [ ] **Authorizable:** the default denial `message:` follows the status: 404 renders ErrorHandleable's exact
147
+ "Resource not found", 401 "Unauthorized", anything else "Forbidden". A `status: :not_found` / `:unauthorized` rule
148
+ without `message:` changes its body message and the `authorization_denied` payload's `message`; an explicit
149
+ `message:` still wins. (HTTP-1)
150
+ - [ ] **Authorizable, `require_role`:** Array arguments are flattened, so `require_role %i[admin editor]` and
151
+ `require_role ADMIN_ROLES` work (they used to deny everyone). (HTTP-2)
152
+ - [ ] **Timezoneable:** responses gain `Vary: Time-Zone` whenever the `Time-Zone` header source is on (default; no longer
153
+ only with `response_header:`) and `Vary: Cookie` whenever the cookie source is on. New `vary:` option (default
154
+ true; `vary: false` opts out). `response_header:` now controls only `X-Time-Zone`. (HTTP-3)
155
+ - [ ] **ErrorHandleable:** the RecordNotDestroyed 422 renders "Resource could not be deleted" instead of Rails'
156
+ "Failed to destroy <Model> with id=<id>"; the record's `errors.full_messages` still ride as `details`. The
157
+ `message` of that 422 and of the `handled_error` payload changed. (HTTP-4)
158
+ - [ ] **Respondable / ErrorEnvelope, status symbols:** on Rack >= 3.1 without Rails 8.1's own rewrite (Rails
159
+ 7.1/7.2/8.0) the gem renders the renamed symbol (`:unprocessable_content`, ...), so `rendered[:status]` is Rack
160
+ dependent; specs asserting `:unprocessable_entity` should compare to `ErrorEnvelope.renderable_status(:unprocessable_entity)`.
161
+ An app's OWN `render_error` override still receives the raw `:unprocessable_entity`; one that calls `render` with
162
+ it logs Rack's deprecation on Rack >= 3.1. (HTTP-5)
163
+ - [ ] **SecureHeadable, `:hsts`:** the `:hsts` preset is skipped when `Rails.application.config.force_ssl` is on (read
164
+ per request), so Rails' own HSTS (`config.ssl_options[:hsts]`) is no longer overridden by the preset's one-year
165
+ value. A custom `"Strict-Transport-Security"` pair with a different value is still sent. (HTTP-6)
166
+ - [ ] **SecureHeadable, header names:** names compare case-insensitively: a lower-case custom
167
+ `"strict-transport-security"` is withheld over plain HTTP, and a lower-case pair beside a preset
168
+ (`"x-frame-options"` + `:deny_frame`) is ONE header (later value wins, first-registered spelling kept), written
169
+ under the spelling the response already carries. (HTTP-7)
170
+ - [ ] **SoftDeletable:** after an association `destroy_all` the association cache is reset rather than marked
171
+ loaded-and-empty, so a `default_scope: false` / `-> { with_deleted }` association still shows its soft-deleted rows
172
+ on the next read (and `size`); one extra query on the next read. (STATE-4)
173
+ - [ ] **Batch verbs, partial `select`:** every `*_all` verb and a SoftDeletable relation `destroy_all` over a relation
174
+ with a partial `select` load whole rows (`table.*` is added; computed aliases a HAVING/ORDER reads stay; joins and
175
+ wheres kept) instead of raising `MissingAttributeError`. Accepted limits: a joined-table column, a Rails 7.1+ Hash
176
+ select or a comma-joined String repeating a column beside the star can still repeat `id` in the DISTINCT key
177
+ subquery on PostgreSQL/MySQL; a direct `BatchOps.each_record` caller selecting without the primary key still
178
+ gets nil ids. (STATE-7)
179
+ - [ ] **Stateable, `transition_all`:** a record failing validation raises `ActiveRecord::RecordNotSaved` (carrying the
180
+ record; batch rolled back as before) instead of `RecordInvalid`; rescue `RecordNotSaved`. Another record's failed
181
+ `save!` inside a hook still propagates as `RecordInvalid`. The single `<event>!` still raises `RecordInvalid`.
182
+ (STATE-8)
183
+ - [ ] **Sequenceable, `period_column:`:** a row numbered before a switch to a custom `period_column:` (NULL there)
184
+ formats (`formatted_<field>`, `sequenceable_period_time`) in its `created_at` period instead of today's. (GRAPH-1)
185
+ - [ ] **Sluggable, `history: true` + `scope:`:** moving a record into a scope where another record's friendly_id history
186
+ holds its slug regenerates the slug (it raised `RecordNotUnique` or took over the old URL). (GRAPH-5)
187
+ - [ ] **Duplicable:** a child or has_one target marked for destruction (`_destroy`, `mark_for_destruction`) is no longer
188
+ copied. (GRAPH-6)
189
+ - [ ] **Legacy aliases:** a racing first reference to a top-level alias (`ConcernsOnRails::Sluggable`) no longer prints
190
+ "already initialized constant". (GRAPH-4)
191
+
192
+
193
+ ### Fixed
194
+
195
+ - **Lockable**: `register_failed_attempt!`, `lock_access!`, `unlock_by_token`, the lapsed-lock clear and the lost-claim
196
+ adoption read no longer raise `ArgumentError` on a composite primary key (Rails 7.1+); every site addresses its row
197
+ through `Support::PrimaryKey`. (DATA-1)
198
+ - **Tokenizable**: `consume_<field>` no longer raises `ArgumentError` on a composite primary key. (DATA-1)
199
+ - **Lockable**: `register_failed_attempt!` on a `readonly!` (or destroyed) record raises `ReadOnlyRecord` /
200
+ `ActiveRecordError` before incrementing; it used to increment in SQL and then raise from `lock_access!`, leaving the
201
+ row at max attempts and unlocked. (DATA-4)
202
+ - **Tokenizable / Lockable**: `authenticate_by_<field>` / `consume_<field>` and `unlock_by_token` answer `nil`, without
203
+ a query, for a token holding a NUL byte (`?token=%00`); on PostgreSQL they raised (the driver refuses NUL in a bound
204
+ parameter). Uses `Support::ScalarParam.nul_free?` like Filterable. (DATA-3)
205
+ - **Encryptable**: a per-field `key:` that resolves to nil or `""` (a Proc over a missing credential, an explicit
206
+ `key: nil`, an eagerly nil `ENV[...]`) no longer falls back to the global key. It raises `MissingKeyError` naming
207
+ the field on write, read and blind-index lookup/refresh, or stays plaintext under `on_missing_key: :passthrough`;
208
+ `key:` must be a String or callable (`ArgumentError` otherwise). It used to seal a key_id-0 envelope under the
209
+ global key that the field key could not decrypt later. (DATA-2)
210
+ - **Authorizable**: the default denial `message:` follows the status (404 "Resource not found", 401 "Unauthorized",
211
+ else "Forbidden"), so a 404 denial is no longer distinguishable from ErrorHandleable's own 404. (HTTP-1)
212
+ - **Authorizable**: `require_role` flattens Array arguments (`require_role %i[admin editor]` used to deny everyone) and
213
+ refuses an empty list or a nil/blank role at class load. (HTTP-2)
214
+ - **SecureHeadable**: the `:hsts` preset is skipped under `config.force_ssl`, so it no longer overrides Rails' own HSTS
215
+ header. (HTTP-6)
216
+ - **SecureHeadable**: header names compare case-insensitively, so a lower-case custom pair beside a preset (or Rails'
217
+ default `X-Frame-Options` on Rails < 7.1) is one header, not two conflicting ones. (HTTP-7)
218
+ - **SoftDeletable**: `really_delete!`, `is_really_deleted?` and the trashed-row purge guard no longer raise on a
219
+ composite primary key. (STATE-3)
220
+ - **SoftDeletable**: `soft_delete!`/`restore!` read the SAVED stamp instead of the unsaved in-memory one; they used to
221
+ return `true` while writing nothing (no hooks, no cascade) after the stamp was assigned unsaved. `restore!` cascades
222
+ by the saved stamp. (STATE-12)
223
+ - **Sortable**: `reposition!` works on a composite primary key (Rails 7.1+). Ids are `[shop_id, id]` tuples (a record
224
+ is accepted too), cast per column and written one UPDATE each inside the transaction; a malformed tuple raises
225
+ `ArgumentError` before any write. A simple key keeps the single CASE UPDATE. It raised `TypeError: can't quote
226
+ Array`. (GRAPH-3)
227
+ - **CounterCacheable**: a composite-key `belongs_to` is refused at the macro (see Upgrading); it was accepted and never
228
+ counted. (GRAPH-2)
229
+ - **Expirable**: `time_until_expiry` no longer raises `TypeError` on a `date` column with a future date. It agrees with
230
+ `expired?` (0 when expired, today counts, in `Time.zone`), otherwise measures to the start of the date in
231
+ `Time.zone`, and is clamped at 0. Also fixes today's date in a zone ahead of UTC answering a positive duration
232
+ while `expired?` was true. (STATE-1)
233
+ - **Expirable**: `expire!` / `expire_in!` on a `date` column fire `before/after_expire` exactly when the written value
234
+ makes the record `expired?`, whatever the process TZ (`Date#to_time` used `ENV["TZ"]` and skipped the hooks for
235
+ hours a day). A blank time (`nil`, `""`, `false`) for `expire!`/`expire_all` is cast through the column like a given
236
+ time (today's `Date` on a `date` column; `expire_all(nil)` writes it). (STATE-2)
237
+ - **Expirable**: `expire_in!(nil)` and `extend_expiry!(by: nil)` raise the concern's `ArgumentError`
238
+ (shared `expirable_check_duration!`). (STATE-10)
239
+ - **Publishable**: `unpublish!` on a draft is idempotent like `publish!`; `unpublish_all` already skipped such rows, so
240
+ verb and batch now agree. (STATE-5)
241
+ - **Anonymizable**: the erasing UPDATE's result is honoured; a row deleted (or unwritable under optimistic locking)
242
+ between load and UPDATE is not counted, and `anonymize!` returns `false` instead of raising from the reload.
243
+ (STATE-9)
244
+ - **HookedWrite** (every hooked verb: `publish!`, `activate!`, `expire!`, `soft_delete!`, Stateable `<event>!`, ...): a
245
+ veto on a NEW record inside a caller's transaction left it `persisted?` with no id; the next `save` ran
246
+ `UPDATE ... WHERE id IS NULL`, returned `true` and wrote no row (`StaleObjectError` with `lock_version`). The
247
+ identity (`new_record?`, `destroyed?`, `previously_new_record?`, id) is now snapshotted and restored for new records
248
+ too. Rails 6.0 never showed the bug; 6.1 through 8.1 did. (STATE-11)
249
+ - **Storable / Auditable**: a PostgreSQL `jsonb` column and an `attribute :x, :json` override over a text column are
250
+ handed the Hash/Array instead of a JSON string scalar, so `where_<key>(v)` matches and `where_<key>(nil)` no longer
251
+ matches every row. One shared `Support::NativeJson.column?` backs both. (TRANS-1)
252
+ - **Normalizable**: no longer re-normalizes a later transform's output at `before_save`: `normalizable :title, with:
253
+ :titleize` plus `sanitizable :title, with: :strip, on: :write` stored `"A &Lt; B"` for a validated `"A &lt; B"`, and
254
+ an entity-decoding rule could store live markup in a `:strip` column. Every transform records what it wrote
255
+ (`Support::TransformBackstop.wrote`) and the settle step skips a field still holding it. (TRANS-2)
256
+ - **Normalizable / Sanitizable**: a save halted by a later `before_save` (or rolled back by `RecordNotUnique`), then
257
+ retried on the same record, no longer re-runs a non-idempotent rule on its own output (`"a!"` no longer becomes
258
+ `"a!!"`, `"Tom &amp; Jerry"` no longer `"Tom &amp;amp; Jerry"`), `update_attribute` saves included. The per-save
259
+ records are dropped after a successful save, kept across a rollback, and dup/clone no longer share them. (TRANS-3)
260
+ - **Taggable**: a tag ending with the start of a self-overlapping delimiter, or starting with its end (`["a-", "b"]`
261
+ under `"--"` read back as `["a", "-b"]`), is trimmed when cleaned; `tag_list=`/`add_tags` clean an Array item by item instead of joining and
262
+ re-splitting. (TRANS-4)
263
+ - **Monetizable**: `price = "1e18"` on an integer cents column casts to `nil` (checked against the column type's own
264
+ range) instead of a value whose save raised `ActiveModel::RangeError`. (TRANS-S3)
265
+ - **Paginatable / Includable**: `paginate_by` and `includable` re-declared in a subclass keep every omitted option
266
+ (an UNSET sentinel, as `cursor_paginate_by`); `paginate_by style:` re-derives both param paths, explicit
267
+ `page_param:`/`per_page_param:` still win; `includable` `fields:`/`default:`/`strategy:` inherit when omitted
268
+ (`default: nil` / `fields: {}` clear them). (PARAMS-2)
269
+ - **Paginatable**: a grouped relation with a `HAVING` on a select alias no longer raises `StatementInvalid`
270
+ (SQLite/MySQL); it is counted as a subquery that keeps its select list. A grouped relation with no `HAVING` or no
271
+ select list keeps the plain `COUNT ... GROUP BY`. (PARAMS-3)
272
+ - **CursorPaginatable**: `predicate: :row` with mixed directions is refused at the macro (see Upgrading); page 1
273
+ minted a cursor and every page-2 request answered 500. (PARAMS-4)
274
+ - **CursorPaginatable**: on SQLite a row whose String ordering value holds a NUL byte no longer ends the walk with a
275
+ 400 on its own cursor; the boundary goes into the keyset WHERE as `CAST(X'<hex>' AS TEXT)`, which assumes a UTF-8
276
+ SQLite database (Rails' default). MySQL already accepted it; PostgreSQL still answers `InvalidCursor`. (PARAMS-5)
277
+ - **Paginatable / CursorPaginatable**: `page` / `per_page` are read as exact whole numbers through
278
+ `Support::NumericOperand` instead of `String#to_i` (`?per_page=1e3` was 1, `?per_page=10abc` was 10); a non-whole
279
+ JSON Float falls back too. (PARAMS-7)
280
+ - **Filterable**: `?<field>_null=` reads only an explicit true/false spelling; any other non-blank value matches
281
+ nothing (see Upgrading). (PARAMS-6)
282
+ - **Filterable**: Encryptable-aware (`Support::EncryptedLookup.rule`), `alias_attribute` followed: equality and `in` use
283
+ the blind index, `not`/`not_in`/`gt`/`gte`/`lt`/`lte`/`contains`/`starts_with` fail closed. `?ssn_not=123` used to
284
+ return the excluded row and `=`/`in`/`contains` silently matched nothing. `null` keeps working on an encrypted
285
+ column. A nil member of an Array operand is dropped. (PARAMS-1)
286
+ - **Filterable**: a Float-column operand is no longer rounded to the nearest double and treated as exact;
287
+ `?score_gt=0.1000000000000000000001` keeps a stored 0.1. A literal the double keeps (`0.1`) stays exact. (PARAMS-8)
288
+ - **Filterable**: a `with:` lambda declared with a `type:` fails closed on an Array/Hash/Parameters value instead of
289
+ receiving a cast one (`?flag[]=0` was `true`). (PARAMS-S4)
290
+ - **Authorizable**: the default `message:` follows the denial status (404 "Resource not found", the shared
291
+ `Support::ErrorEnvelope::NOT_FOUND_MESSAGE`; 401 "Unauthorized"; else "Forbidden"), stored resolved at declaration so
292
+ the instrumentation payload carries it. `require_role` flattens its arguments and raises on an empty, nil or blank
293
+ role. (HTTP-1, HTTP-2)
294
+ - **ErrorHandleable**: the RecordNotDestroyed 422 no longer leaks the model class name and primary key. (HTTP-4)
295
+ - **Timezoneable**: `Vary: Time-Zone` / `Vary: Cookie` are appended (via `Support::VaryHeader`; `Vary: *` left alone)
296
+ even when the request carries no `Time-Zone` header; `vary: false` opts out. (HTTP-3)
297
+ - **Respondable / ErrorEnvelope**: the envelope and inline branches no longer log "Status code :unprocessable_entity is
298
+ deprecated" on Rack >= 3.1; `ErrorEnvelope.renderable_status` renames only when the running Rack dropped the old
299
+ name (Rack < 3.1 keeps it). (HTTP-5)
300
+ - **SecureHeadable**: `:hsts` yields to `force_ssl`; header names merge case-insensitively through
301
+ `SecureHeadable.merge_headers` (no more two conflicting `X-Frame-Options` lines on Rails < 7.1). (HTTP-6, HTTP-7)
302
+ - **SoftDeletable**: association `destroy_all` resets the association cache instead of `loaded!`-empty. (STATE-4)
303
+ - **BatchOps / SoftDeletable**: `BatchOps.full_rows(relation)` (used by `BatchOps.run` and the relation `destroy_all`)
304
+ adds the model's own star to a partial select; `limited_keys` keeps the own-table star first and drops only plain
305
+ own-table columns from a DISTINCT key subquery. (STATE-7)
306
+ - **Stateable**: an event named `transition` is refused at the macro (STATE-6); `transition_all` raises
307
+ `RecordNotSaved` for its own invalid record and propagates another record's `RecordInvalid` (STATE-8).
308
+ - **Sequenceable**: legacy rows (NULL custom `period_column:`) format in their `created_at` period; `period_time` /
309
+ `base_time` take the field. (GRAPH-1)
310
+ - **Sluggable**: a `history: true` + `scope:` move treats another record's history slug in the new scope as taken.
311
+ (GRAPH-5)
312
+ - **Duplicable**: records marked for destruction are skipped, loaded or not, has_one included. (GRAPH-6)
313
+ - **Legacy aliases**: `const_missing` resolution is serialized by a reentrant Monitor. (GRAPH-4)
314
+
315
+
316
+ ### Known issues (not fixed in this release)
317
+ - CounterCacheable: a parent destroyed inside the same rolled-back transaction keeps the
318
+ mirrored counter/lock_version in memory.
319
+ - Aliasable: eager-loading an alias together with its source in one query loads each child twice.
320
+ - Encryptable: on the pre-7.1 Marshal format, a record whose field has a per-field Proc `key:`
321
+ cannot be `Marshal.dump`ed (`Rails.cache`).
322
+ - CursorPaginatable: on PostgreSQL, a page ending on a timestamp stored as `'infinity'` now mints
323
+ its cursor (it used to be a 500), but the next page answers 400 — the walk cannot step past it.
324
+ - Normalizable / Sanitizable: on an unvalidated (`update_attribute`) save that is halted and retried, with TWO
325
+ transforms on one column where the SECOND (not Normalizable) rewrites the first's output, Sanitizable's own record
326
+ holds only the first's output, so a validated retry re-sanitizes.
327
+ - Sluggable `history: true` + `scope:`: friendly_id's history finder ignores scope, so after a record moves scope its
328
+ stale history slug can still match `Model.where(scope).friendly.find(old_slug)`. This is stock friendly_id
329
+ behaviour; the gem does not change it. (GRAPH-5)
330
+
331
+
3
332
  ## 1.33.0 (2026-10-18)
4
333
 
5
334
  A correctness release: it fixes the 66 bugs proven by the 2026-10 audit of 1.32.1. Some fixes change