concerns_on_rails 1.30.0 → 1.32.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +372 -0
  3. data/README.md +131 -52
  4. data/lib/concerns_on_rails/configuration.rb +2 -1
  5. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +225 -52
  6. data/lib/concerns_on_rails/controllers/deprecatable.rb +7 -3
  7. data/lib/concerns_on_rails/controllers/filterable.rb +58 -5
  8. data/lib/concerns_on_rails/controllers/idempotentable.rb +29 -1
  9. data/lib/concerns_on_rails/controllers/paginatable.rb +124 -1
  10. data/lib/concerns_on_rails/controllers/permittable.rb +1 -0
  11. data/lib/concerns_on_rails/controllers/throttleable.rb +10 -2
  12. data/lib/concerns_on_rails/controllers/timezoneable.rb +2 -0
  13. data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +13 -7
  14. data/lib/concerns_on_rails/core.rb +93 -0
  15. data/lib/concerns_on_rails/models/activatable.rb +14 -4
  16. data/lib/concerns_on_rails/models/aliasable.rb +1 -0
  17. data/lib/concerns_on_rails/models/anonymizable.rb +144 -33
  18. data/lib/concerns_on_rails/models/auditable.rb +81 -19
  19. data/lib/concerns_on_rails/models/counter_cacheable.rb +389 -19
  20. data/lib/concerns_on_rails/models/duplicable.rb +152 -59
  21. data/lib/concerns_on_rails/models/encryptable.rb +337 -44
  22. data/lib/concerns_on_rails/models/expirable.rb +29 -10
  23. data/lib/concerns_on_rails/models/hashable.rb +30 -3
  24. data/lib/concerns_on_rails/models/lockable.rb +174 -26
  25. data/lib/concerns_on_rails/models/normalizable.rb +46 -3
  26. data/lib/concerns_on_rails/models/publishable.rb +54 -11
  27. data/lib/concerns_on_rails/models/schedulable.rb +18 -3
  28. data/lib/concerns_on_rails/models/searchable.rb +25 -2
  29. data/lib/concerns_on_rails/models/sequenceable.rb +209 -23
  30. data/lib/concerns_on_rails/models/sluggable.rb +161 -7
  31. data/lib/concerns_on_rails/models/soft_deletable.rb +33 -8
  32. data/lib/concerns_on_rails/models/sortable.rb +1 -0
  33. data/lib/concerns_on_rails/models/stateable.rb +321 -20
  34. data/lib/concerns_on_rails/models/storable.rb +71 -44
  35. data/lib/concerns_on_rails/models/taggable.rb +21 -0
  36. data/lib/concerns_on_rails/models/tokenizable.rb +51 -7
  37. data/lib/concerns_on_rails/railtie.rb +41 -3
  38. data/lib/concerns_on_rails/support/affix.rb +67 -2
  39. data/lib/concerns_on_rails/support/association_scope.rb +121 -0
  40. data/lib/concerns_on_rails/support/batch_ops.rb +87 -2
  41. data/lib/concerns_on_rails/support/callable.rb +70 -0
  42. data/lib/concerns_on_rails/support/encrypted_lookup.rb +53 -0
  43. data/lib/concerns_on_rails/support/generated_values.rb +63 -0
  44. data/lib/concerns_on_rails/support/hooked_write.rb +76 -61
  45. data/lib/concerns_on_rails/support/locking.rb +126 -0
  46. data/lib/concerns_on_rails/support/masker.rb +35 -8
  47. data/lib/concerns_on_rails/support/money.rb +11 -3
  48. data/lib/concerns_on_rails/support/numeric_operand.rb +26 -5
  49. data/lib/concerns_on_rails/support/scalar_param.rb +7 -2
  50. data/lib/concerns_on_rails/support/sequence_calculator.rb +50 -2
  51. data/lib/concerns_on_rails/support/slug_sources.rb +60 -0
  52. data/lib/concerns_on_rails/support/time_value.rb +306 -0
  53. data/lib/concerns_on_rails/version.rb +1 -1
  54. data/lib/concerns_on_rails.rb +14 -74
  55. metadata +20 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01db17beb47c485890bfd3c755086624432941547fdd77ecc52536164a2a3404
4
- data.tar.gz: 8aeca6a8b1e4d901a8d1b128a75ff27f25f5f85138034819b75ed66cfb16cc4e
3
+ metadata.gz: a5638a56a20ce9b346dd013448af31656fe6925eaefbf8bd87ee8906c17825b4
4
+ data.tar.gz: b3feeb0feaab7f14bf5d03ea46a9e7a1a69f0d087173cf2fb0baa1efb2c86daa
5
5
  SHA512:
6
- metadata.gz: 7615ea7a35f68bffb71e55975a9ad3097d683287dd1cddc81d4d0397343fba1d38289a8589db573ebe3a5f310ed5e2ddf43f621082f6bc16f56587402594df0e
7
- data.tar.gz: '093ac4ae4b2de63d42722de0f75b23ce9423cc8528fa6c1768e849a515079d501b5df095e64c54227cd967d355ce302c63d55fe4467b7903083770ff38115adc'
6
+ metadata.gz: 25623c6d25c7fafc8e8c4dfd5f16350df059a5f0bb2999c0d457b986cfcff1c55365f8e072ab5dd01cb9ae48e6226ff335a546f02c1521ca0c3934898e92c296
7
+ data.tar.gz: ad5dd6aa2c310c9d711fd9513e01488e79f164df54aa2accdbca8ab9a46f3c2fe296c0733c6b0c404c0df09147c28160f7c5177e0938b6637eb3037d71f1e363
data/CHANGELOG.md CHANGED
@@ -1,5 +1,377 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.32.0 (2026-10-07)
4
+
5
+ A dependency release (#127): the `permittable` floor rises to `>= 0.8`, and the Permittable docs
6
+ are rewritten against permittable 0.10. It is a minor release because hosts still on permittable
7
+ 0.1–0.7 must upgrade it (the same rule as the Rails floor raise in 1.29.0). No code change, no
8
+ new concerns and no migrations. 2723 examples, 0 failures, green on PostgreSQL, MySQL and every
9
+ Rails line from 6.0 to 8.1.
10
+
11
+ ### Changed
12
+ - **`permittable` dependency floor raised from `~> 0.1` to `>= 0.8, < 1`.** The Permittable
13
+ documentation (README section and docs page) was rewritten against permittable 0.10 — monitor
14
+ mode, `:json` fields, `nullable:`, `message:`/I18n copy, `format:` presets, field groups,
15
+ RFC 9457 output, the type-checking drift guard, the `permittable:generate`/`:audit`/`:openapi`
16
+ tasks, RSpec matchers and standalone contracts — and the old pin let a host resolve a 0.x that
17
+ had none of it. 0.8 is the floor because it is the newest line that still supports Rails 6.0
18
+ (0.9+ requires ActiveSupport >= 6.1), so the 6.0 CI line keeps resolving; the docs carry a
19
+ heads-up listing what a 0.8 host lacks and how Respondable's `:problem_details` and the gem's
20
+ own `Permittable.error_format = :problem` interact. No code change; the gem README stays the
21
+ canonical Permittable reference.
22
+
23
+ ## 1.31.0 (2026-10-05)
24
+
25
+ The fixes from two audits, each fix put through adversarial review rounds until a reviewer
26
+ signed it off: the 2026-09-25 audit of 1.30.0 (#116–#120) and the 2026-09-30 audit of this
27
+ release's own candidate (#123–#125: values generated in `before_create`, optimistic locking
28
+ around the gem's raw writes, and time values), plus dependency refreshes (#121, #126). It is a
29
+ minor release because Sequenceable gains an option and a reader, and several fixes change
30
+ observable behaviour or raise where the gem used to silently misbehave. Read **Changed** before
31
+ upgrading: blind indexes of generated encrypted values, and of `:datetime` fields, want a
32
+ one-off backfill. No new concerns and no migrations. Every fix ships with a regression spec.
33
+ 2723 examples, 0 failures (2694 on Rails 6.0), green on PostgreSQL and MySQL.
34
+
35
+ ### Changed
36
+ - **Sequenceable: `reset:` periods are cut in one fixed zone**, not the per-request
37
+ `Time.zone`. The `MAX` range and the period token both use the new `time_zone:` (default:
38
+ `config.time_zone`, resolved at use time, else UTC). Before, two requests in different zones
39
+ (Timezoneable, `Time.use_zone`) could issue the **same number**, and `formatted_<field>`
40
+ without `into:` rendered a different date for readers in different zones. Apps that keep
41
+ `Time.zone == config.time_zone` see no change. With `into:` and `reset:` (no `template:`) the
42
+ next number also accounts for rows whose stored value already carries the period's `prefix +
43
+ token + separator`, so a number stamped under a request zone before the upgrade is continued,
44
+ never reissued; without `into:`, add a unique index before upgrading. (#116)
45
+ - **Sequenceable: re-declaration.** A call passing **only `assign:` and/or `time_zone:`** keeps
46
+ the rest of the field's current config, so `sequenceable_by :sequence, assign: :manual` on a
47
+ `Draft` subclass keeps the parent's `into:`/`prefix:`/`reset:`/`template:`. Every other
48
+ re-declaration, a bare one included, restates the format from the defaults as before. A
49
+ re-declaration whose full format tuple is identical to an inherited owner's now shares that
50
+ owner's counter, instead of numbering a subclass-only series that could reissue its numbers
51
+ (Procs compare by identity, so a draft subclass should re-declare only `assign:`).
52
+ **Upgrading:** a subclass declared the `assign:`-only way used to number its own
53
+ default-format series; its rows without an `into:` value now render in the parent's format,
54
+ so backfill `into:` first. (#116)
55
+ - **Sequenceable: classes sharing a counter under `reset:` must cut the same periods.** Both
56
+ `time_zone:` omitted, or both explicit and the same zone (compared by canonical TZInfo
57
+ identifier, then by UTC offsets 1970–2100, so `"Kolkata"`/`"Asia/Calcutta"` match), else
58
+ `ArgumentError` at class load. Declare `time_zone:` on the class that first declares a
59
+ format, and declare an owner fully before its subclasses. (#116)
60
+ - **CursorPaginatable: nullable ordering columns paginate, NULLs last** in both directions on
61
+ every adapter (PostgreSQL `NULLS LAST`, a `CASE` sort key elsewhere; the keyset WHERE uses
62
+ the OR-expansion). NULL-ordered rows used to be silently dropped, and NULLs sorted first
63
+ under PostgreSQL DESC and SQLite/MySQL ASC. NOT NULL columns keep the same SQL. An ordering
64
+ column the relation did not select raises `ActiveModel::MissingAttributeError`, a stored
65
+ non-NULL value that casts to nil raises `ArgumentError`, and ordering columns must be
66
+ selected unaliased. (#117)
67
+ - **CursorPaginatable**: re-declaring `cursor_paginate_by` (e.g. in a subclass) changes only
68
+ the options passed and inherits the rest, so a subclass setting only `order:` no longer
69
+ silently disables the parent's `signed:`. (#117)
70
+ - **Paginatable**: a `DISTINCT` relation with its own `SELECT` list
71
+ (`select(:group_id).distinct`) is counted as a subquery of the relation's own SQL, so
72
+ `X-Total-Count` / `X-Total-Pages` count the distinct rows returned, not every underlying row.
73
+ Only MySQL, which rejects repeated output names in a derived table (error 1060, recognised by
74
+ driver code), retries with the select list re-aliased, and falls back to the previous
75
+ over-count if that fails too. (#117)
76
+ - **Stateable: name collisions raise.** `stateable_by` raises `ArgumentError` when a generated
77
+ `<state>?`/`<state>!`/`<event>!`/`may_<event>?` method or `.<state>` scope would override a
78
+ method the class already has from ActiveRecord or another concern — an event `lock`
79
+ overriding AR's `lock!` (which broke `with_lock` and `lock: true`), an event `restore` next
80
+ to SoftDeletable, a state `active` next to Activatable. Use `prefix:`/`suffix:`. The reverse
81
+ order raises too. One exemption: a state may take a scope that is still SoftDeletable's,
82
+ Publishable's or Schedulable's untouched include-time default on the same class, so that
83
+ concern's own macro can rename it — include that concern before `stateable_by`; only its
84
+ affixing macro may come after. An unrenamed collision raises when the shared scope is called.
85
+ (#120)
86
+ - **Stateable**: a re-declaration retires the methods and scopes the earlier declaration
87
+ generated and the new one no longer lists (a subclass with `states: %i[open closed]` no
88
+ longer answers its parent's `archive!` or `.draft`). Only methods Stateable itself defined
89
+ are retired; a user's own method under such a name is left alone. The attribute default no
90
+ longer forces a `:string` type onto the state column (a PG enum/citext or host-declared
91
+ `attribute` type is kept, per declaring class), and a re-declaration without `default:` keeps
92
+ the earlier default only while it is still a declared state. (#120)
93
+ - **Publishable**: `default_scope:` is a flag read by one lazily registered default scope,
94
+ added only the first time a class passes `default_scope: true`, so a model that never asks
95
+ for it keeps Rails' statement cache. An explicit `default_scope: false` on a later call or an
96
+ STI subclass turns it off; omitting the option keeps the inherited setting. (#120)
97
+ - **Encryptable**: an encrypted field used as a friendly_id slug source raises `ArgumentError`
98
+ — the `sluggable_by` field, a `candidates:` entry, an `alias_attribute` of the field (chains
99
+ followed) or a bare friendly_id base — at declaration where it can, otherwise at save, before
100
+ the row is written. The slug used to store the field's plaintext. **Upgrading:** regenerate
101
+ those slugs from a safe field and delete the stale `friendly_id_slugs` rows (see the
102
+ Encryptable docs). (#118)
103
+ - **SoftDeletable `cascade:` / Duplicable** now reach children hidden by Publishable's
104
+ `default_scope: true` (drafts). Only the gem's own hiding predicates on the child's own table
105
+ are removed; every other default-scope predicate still applies, as for Rails' `dependent:` —
106
+ a tenant scope, a discriminator on a shared table, a joined table's same-named column. A
107
+ `has_one` cascades to / copies the child its reader returns. Duplicable never copies a
108
+ SoftDeletable child's soft-deleted rows into a `has_many`/`has_one` copy, whatever its
109
+ `default_scope:` (with it off, the copy used to get them back as live rows). (#119)
110
+ - **Normalizable**: saves that skip validation (`update_attribute`, `save(validate: false)`)
111
+ are normalized by a prepended `before_save` backstop, so Encryptable's blind index, Auditable
112
+ and Addressable see the normalized value whatever the include order. (#120)
113
+ - **CounterCacheable**: re-declaring the same association + `count:` column replaces the
114
+ earlier rule (it double-counted); an STI subclass can narrow an inherited counter with `if:`;
115
+ on an STI table `recount_counter_caches!` tallies every class's rows under its own rule,
116
+ resolving each stored type without building a record (an unknown type counts under the base
117
+ rule instead of raising). (#120)
118
+
119
+ - **Upgrading (Encryptable + Tokenizable/Hashable/Sequenceable):** rows created before this
120
+ release with a generated, blind-indexed encrypted value (a token, code or number) were stored
121
+ with a **NULL** blind index. They stay unfindable by `find_by_<field>` / `where_<field>` /
122
+ `authenticate_by_<field>` / `consume_<field>` until backfilled. `reencrypt_all!` does not
123
+ reach them, because they are already under the current key. Run once per field:
124
+ `Model.unscoped.where(<field>_bidx: nil).where.not(<field>: nil).find_each { |r|
125
+ r.update_columns(<field>_bidx: Model.<field>_fingerprint(r.<field>)) }` (no callbacks, any
126
+ key). See `docs/concerns/encryptable.md#upgrading-blind-indexes-of-generated-values`. (#123)
127
+ - **Searchable:** `searchable_by` over an `encryptable` column (or `encryptable` over a searched
128
+ one) raises `ArgumentError` at declaration. Before, `search` ran `LIKE` against ciphertext and
129
+ silently returned nothing. (#123)
130
+ - **Taggable:** `taggable_by` over an `encryptable` column (or `encryptable` over the tag
131
+ column) raises `ArgumentError` at declaration. A model that includes Taggable without
132
+ `taggable_by` and encrypts its default `:tags` is refused when `tagged_with` is called. New
133
+ `taggable_declared` class attribute. (#123)
134
+ - **Tokenizable:** on an `encryptable :<field>, blind_index: true` token,
135
+ `authenticate_by_<field>` / `consume_<field>` look the token up through the blind index (still
136
+ `secure_compare`d), and `consume_<field>` clears the index along with the token. Encrypted
137
+ without a blind index, they raise `ArgumentError` when called; before, they returned `nil` for
138
+ every token. Generating, storing and rotating an encrypted token work as before. (#123)
139
+ - **Anonymizable:** with `lock_version`, `anonymize!` / `anonymize_all!` write their single
140
+ UPDATE through `update_all`, bumping `lock_version` in SQL (the row's value + 1). An edit form
141
+ loaded before the erasure now raises `StaleObjectError` instead of writing the personal data
142
+ back. A stale anonymizing instance now erases; on Rails 7.0+ its constrained `update_columns`
143
+ used to erase nothing while returning `true`. `update_columns`' refusals (destroyed,
144
+ `readonly!`, `attr_readonly`) are kept. (#124)
145
+ - **Expirable:** `extend_expiry!` extends the **stored** expiry, read under a row lock, so
146
+ concurrent extensions compound (two `+30.days` renewals give +60, not +30). An expiry assigned
147
+ but not yet saved, and a new record, still extend from the instance's value. With optimistic
148
+ locking, a stale instance raises `StaleObjectError` rather than losing a renewal. (#124)
149
+ - **Encryptable:** `type: :datetime` behaves like a datetime column on the same model. Under
150
+ `time_zone_aware_attributes`, a zone-less String (a `datetime-local` form value) and a
151
+ `datetime_select` value are wall-clock time in `Time.zone`, a `Date` is midnight there, and
152
+ the field reads back as an `ActiveSupport::TimeWithZone`. Otherwise it follows
153
+ `ActiveRecord.default_timezone`. It is exactly the handling Rails gives `attribute :name,
154
+ :datetime` (ActiveRecord wraps the type in its own `TimeZoneConverter`), so `normalizes` and a
155
+ parent's re-declaration are inherited by subclasses.
156
+ `skip_time_zone_conversion_for_attributes` (Symbols) is honored when set before the
157
+ `encryptable` line; on Rails 6.0–7.1 wherever it is set. From Rails 7.2, where Rails decides
158
+ at declaration, a skip list set later or on a subclass raises `ArgumentError` when the model
159
+ is first used, unless the field is re-declared right after it. The plaintext stays UTC
160
+ ISO8601, so existing rows read as the same instant. A stored plaintext without `Z` or an
161
+ offset is read in `default_timezone`, never the server's zone. (#125)
162
+ - **Encryptable:** a blind index fingerprints the field's canonical plaintext (the value cast
163
+ through its `type:`, a `:datetime` as UTC ISO8601) on write and on lookup. `find_by_<field>` /
164
+ `where_<field>` on a `:datetime` field find the record by any rendering of the instant,
165
+ whatever `Time.zone` the writer, the reader or `reencrypt!` ran in, and a String finds a typed
166
+ field (`find_by_age("042")`). `expression:` receives the cast value (a `:datetime` as a UTC
167
+ `Time`). Lookups also try the digest the index used before, so existing rows stay findable by
168
+ the lookup that found them. `:datetime` fields should be reindexed once (recipe in
169
+ `docs/concerns/encryptable.md#blind-index-of-a-typed-field`, which skips rows it cannot
170
+ decrypt); every other type's digest is unchanged. A non-numeric String on an `:integer` field
171
+ finds nothing, as an integer column's `where`, and a value that does not cast
172
+ (`Float::INFINITY`) is stored as `NULL` with no digest. (#125)
173
+ - **Storable:** `:datetime` keys behave like a datetime column on the same model, with the same
174
+ `Time.zone` / `default_timezone` rules as Encryptable. The reader returns an
175
+ `ActiveSupport::TimeWithZone` under `time_zone_aware_attributes`, `where_<key>` parses its
176
+ argument the same way as the writer, a `datetime_select` value is wall-clock time in
177
+ `Time.zone`, and a `default:` (a String, a `Time` or a Proc) reads back as the same value
178
+ stored would. List the accessor in `skip_time_zone_conversion_for_attributes` to opt a key
179
+ out. The stored UTC ISO8601 form is unchanged. (#125)
180
+ - **Publishable / SoftDeletable / Schedulable / Expirable:** `publish_at!`, `soft_delete!(at:)`,
181
+ `start!`, `finish!`, `reschedule!`, `expire!` and `expire_all` raise `ArgumentError` before
182
+ any hook runs when given a value that is not a time: a String must read as a date the way
183
+ `Time.zone.parse` will read it: a four-digit year, no ordinal day, every part in range, years
184
+ 0001–9999, and only date words or a time zone (ISO 8601, RFC 2822 with its comments, HTTP
185
+ dates, JavaScript `Date#toString`, Go and RFC 9557 forms, `"Oct 1 2026"`, `"2026-10-01 10:30
186
+ EST"` and `"10:30 p.m."` all pass; `"junk"`, `"Monday"`, `"10:30"`, `"junk 2026"`, `"Oct 1
187
+ 26"`, `"15th of October 2026"` and `"7:30p"` do not, although `Time.zone.parse` reads them as
188
+ June 1st, today, year 26 AD, October 1st or military zone P), and `42` or `1.hour` never cast.
189
+ The first five used to write `NULL` and still return `true`; Expirable accepted the yearless
190
+ words in time-zone-aware apps. Column assignment is unchanged. The check is the shared
191
+ `Support::TimeValue.cast_argument!`. (#125)
192
+ - **SoftDeletable:** `soft_delete!(at: nil)` or a blank `at:` means now, as for `expire!`.
193
+ `soft_delete!(at: params[:at])` with the param absent used to write `NULL`, fire the hooks and
194
+ return `true` with nothing deleted. `publish_at!(nil)` (and blank) still writes `NULL` and
195
+ fires the publish hooks. (#125)
196
+
197
+ ### Added
198
+ - **Sequenceable**: `time_zone:` (a zone name as a String or Symbol, or an
199
+ `ActiveSupport::TimeZone`; an unknown zone raises at class load) and
200
+ `sequenceable_period_time(field)`, the fixed-zone period instant for `template:` lambdas that
201
+ render a date. (#116)
202
+
203
+ ### Fixed
204
+ - **Sequenceable**: re-declaring a field with `assign: :manual`, on the same class or an STI
205
+ subclass, really stops numbering at create. Each call used to add its own `before_create`
206
+ lambda, and the earlier `:create` one kept firing. Each field now has one symbol callback,
207
+ registered at its first `assign: :create` declaration, honouring the receiving class's
208
+ current declaration. (#116)
209
+ - **Paginatable**: `paginated(Model)` / `pagination_meta(Model)` with a bare model class no
210
+ longer raises. A non-finite JSON-body `page`/`per_page` (`1e400`, NaN) falls back to the
211
+ default instead of a 500 (Paginatable and CursorPaginatable). (#117)
212
+ - **CursorPaginatable**: boundary values are read from the stored column, so an overridden
213
+ attribute reader no longer makes the walk repeat pages forever, and a value changed in memory
214
+ (an `after_initialize` default) no longer keys the cursor on a value the row's sort position
215
+ does not have. (#117)
216
+ - **Callable options**: Throttleable `by:`, WebhookVerifiable `secret:`, Deprecatable
217
+ `notify:`, CounterCacheable `if:` and Auditable `actor:` accept every callable the macro
218
+ validates. A lambda with no required parameter or a block is `instance_exec`'d as before; a
219
+ lambda with a required parameter or a symbol proc is called with the controller (the record
220
+ for `if:`/`actor:`); any other callable object gets it only when its `#call` requires an
221
+ argument, and is otherwise called bare. These used to raise `TypeError`/`ArgumentError` (for
222
+ `actor:`, on every save). Throttleable's `if:`/`unless:` keep their own dispatch. (#117)
223
+ - **Anonymizable**: a callable-object `with:` strategy no longer raises `NoMethodError`
224
+ (`#arity`). (#117) A rewritten slug counts as an erased field for `clear_audit_trail:`,
225
+ `slug: :auto` sees a slug built from an `alias_attribute` of an erased column, erasing an
226
+ Addressable-mapped column clears its `fingerprint:` in the same UPDATE, and
227
+ `prefix:`/`suffix:` passed on a later call are honoured (old scope names retired safely).
228
+ (#118)
229
+ - **Idempotentable**: a replay carries the original's exact `Content-Type`, so
230
+ `application/problem+json` is no longer replayed with `; charset=utf-8`. (#117)
231
+ - **Monetizable**: underscore-grouped amounts (`"5_5"`, `"1_000"`) are rejected (nil) like
232
+ other mis-grouped input. (#117)
233
+ - **Filterable**: a nonzero float operand that underflows to 0.0 (`"1e-400"`) is treated as
234
+ inexact, so `=` matches nothing and `<`/`>` compare correctly. (#117)
235
+ - **Maskable**: presets stringify non-String values before masking instead of returning them
236
+ raw (integer SSN/phone columns were shown unmasked); integral BigDecimal/Float values render
237
+ without `.0`. (#118)
238
+ - **Lockable**: `lock_access!` is a conditional UPDATE — a concurrent stale instance adopts the
239
+ existing lock instead of re-minting the mailed unlock token and re-firing `after_lock`;
240
+ `before_lock` runs after the row is claimed. `register_failed_attempt!` clears a lapsed lock
241
+ the same way: a stale instance used to wipe a lock another request had just taken (a second
242
+ token and `after_lock`, or, below `max_attempts`, an account left unlocked). (#118)
243
+ - **Loader**: every concern file works when required directly (`gem "concerns_on_rails",
244
+ require: false`). The gem-level singletons live in `concerns_on_rails/core`, and a directly
245
+ required file now also wires its sensitive fields into Rails' `filter_parameters` and
246
+ ActiveRecord's `filter_attributes` whether required before, during (eager load,
247
+ `config/initializers`) or after boot — unlock tokens used to be logged in clear and
248
+ `#inspect` showed decrypted values. Deprecatable/Timezoneable require the ActiveSupport time
249
+ extensions they use. (#118)
250
+ - **Sluggable**: a re-declaration refused by the column check no longer leaves its field
251
+ assigned. **Duplicable**: sibling-concern detection no longer autoloads Sluggable/friendly_id
252
+ (or raises without the loader). (#118)
253
+ - **Batch verbs** (`*_all`, `anonymize_all!`, `reencrypt_all!`, `recount_counter_caches!`, the
254
+ cascade) on their per-record path act on exactly the rows an ordered or limited relation
255
+ selects (`order(id: :desc).limit(2).publish_all` touched the two lowest ids), no longer raise
256
+ under `error_on_ignored_order`, and visit each record once over a `has_many` join (hooks ran
257
+ once per joined row); a limited joined slice instantiates one record per row. (#119)
258
+ - **Duplicable**: identity columns (token, slug, sequence number, audit trail, lock state) are
259
+ reset on copies of children that do not include Duplicable; they used to share the original's
260
+ token or raise `RecordNotUnique`. (#119)
261
+ - **Hooked writes** (`publish!`, a Stateable event, `soft_delete!`, `expire!`, `activate!`,
262
+ `anonymize!`): a veto or failure restores every in-memory attribute, including a nested
263
+ in-place edit of an already-read json value, never decrypting anything. (#119)
264
+ - **Auditable**: works on a native `json`/`jsonb` trail column (declare it without a default —
265
+ MySQL rejects a literal JSON `DEFAULT`); earlier double-encoded trails still read, and
266
+ `audit_trail` entries are detached from the column value. (#119)
267
+ - **Searchable**: `mode: :any` strips surrounding whitespace from the query. **Storable**:
268
+ every non-Proc `default:` is deep-duped per read. (#120)
269
+
270
+ - **Encryptable:** a blind-indexed value that Tokenizable, Hashable or Sequenceable generates in
271
+ `before_create` is now fingerprinted when it is generated. Before, the row was stored with a
272
+ NULL blind index, so `find_by_<field>` never found a freshly created record. Works in either
273
+ include or declaration order. (#123)
274
+ - **Sluggable:** a slug whose source is filled in `before_create` (Sequenceable's `into:`
275
+ number, a Hashable code) is now built in the same `INSERT`, not left `nil` until a later save.
276
+ friendly_id's rules still apply: an explicit slug is kept, a conflict gets the uuid suffix,
277
+ and `candidates:` are honored. The late slug is built after every
278
+ `before_validation`/`before_save`, so it gets what those would have done: a reserved word gets
279
+ the uuid suffix, and the slug column's own write-mode Sanitizable and Normalizable rules are
280
+ applied (`normalizable :slug, with: ->(v) { v.tr("-", "_") }` → `"inv_1"`). (#123)
281
+ - **Sluggable:** a slug built earlier in the same save from a source that a later
282
+ `before_validation` then transformed (Sanitizable `on: :write`, Normalizable, your own
283
+ callback, included after Sluggable) is rebuilt from the final value at the start of
284
+ validation. Before, `"<b>Hello</b>"` got the slug `"b-hello-b"`. The rebuild runs before every
285
+ validator (`reserved_words:` included) and every sibling `before_save`, and never touches an
286
+ explicitly assigned slug. (#123)
287
+ - **Auditable:** the creation entry now includes a tracked column generated in `before_create`
288
+ (a Sequenceable number, a Tokenizable/Hashable value, or a slug built from one), whatever the
289
+ include order. (#123)
290
+ - **Hashable:** the report of a generated code to its siblings is a separate `before_create
291
+ :hashable_report_generated_value`, right after the unchanged `before_create
292
+ :assign_hashable_value`. Calling `assign_hashable_value` yourself, outside a save, only
293
+ assigns the value. (#123)
294
+ - **Tokenizable / Hashable:** the uniqueness precheck on an encrypted field goes through the
295
+ blind index. It used to compare against ciphertext and could never detect a collision. (#123)
296
+ - **Lockable:** with optimistic locking (`lock_version`), the raw writes —
297
+ `register_failed_attempt!`'s increment, the `lock_access!` claim, the quiet lapsed-lock reset,
298
+ and `unlock_by_token`'s token claim — bump `lock_version` in SQL, and the bump is now mirrored
299
+ into the instance exactly as Rails' `increment!` does. The same instance, and the record
300
+ `unlock_by_token` returns, save afterwards instead of raising `StaleObjectError`. On Rails
301
+ 7.0+ this also fixes `unlock_by_token` leaving the account locked: the stale instance's
302
+ `update_columns` matched no row while the call reported success. A hook that aborts takes the
303
+ in-memory bump back. (#124)
304
+ - **CounterCacheable:** with `lock_version` on the parent, the parent instance the child's
305
+ association holds (`post.comments.create!`'s `post`, the one passed as `post:`, the old parent
306
+ a foreign-key reassignment left loaded, the parent on destroy) gets the counter delta and the
307
+ `lock_version` bump mirrored in memory — the in-memory half of native `counter_cache`'s
308
+ `increment!`. `post.comments.create!(...); post.update!(...)` no longer raises
309
+ `StaleObjectError`. A counter you assigned and haven't saved is left exactly as assigned,
310
+ still pending (native `increment!` would add the delta to it), while `lock_version` is still
311
+ bumped. When the child's save is rolled back — your `ActiveRecord::Rollback`, a later callback
312
+ raising, a Stateable/HookedWrite hook vetoing, a rolled-back savepoint, a rolled-back
313
+ `transaction(joinable: false)` — the mirror is taken back off the parent instance, so it stays
314
+ saveable (native `counter_cache` leaves it stale). Only the adjustments that were actually
315
+ rolled back are undone, and a counter or `lock_version` you assigned after the mirror is left
316
+ as you assigned it. The undo holds the parent only weakly, once per parent and transaction, so
317
+ long batches keep nothing in memory, and the parent stays `Marshal`-dumpable. An
318
+ `attr_readonly` counter is not mirrored. Known limit: a parent destroyed later in the same
319
+ rolled-back transaction keeps its mirror (reload it). Without `lock_version`, nothing changes.
320
+ (#124)
321
+ - **Encryptable:** `reencrypt!` / `reencrypt_all!` no longer bump `lock_version`, since the
322
+ column is pinned to itself in the rotation UPDATE. A key rotation changes no value, so records
323
+ open in edit forms during the sweep no longer turn into `StaleObjectError`s. The ciphertext
324
+ guard is unchanged. (#124)
325
+ - **Stateable:** `lock: true` works on records with unsaved changes (`ticket.note = "fixed";
326
+ ticket.resolve!` saves the note with the state, as `lock: false` does); `with_lock`'s reload
327
+ used to raise "Locking a record with unpersisted changes is not supported". For such a record
328
+ only the state column is read (`SELECT <state> ... FOR UPDATE`) and adopted, and the guard
329
+ decides from it (a disallowed event raises `InvalidTransition`). On a model with optimistic
330
+ locking, a record with unsaved changes whose row has changed since it was loaded raises
331
+ `StaleObjectError`: its changes were made against a stale copy. A deleted row raises
332
+ `RecordNotFound`. A record without unsaved changes is still reloaded under the lock, exactly
333
+ as before. (#124)
334
+ - **Activatable:** `toggle_active!` works on records with unsaved changes. It used `with_lock`
335
+ too, and raised the same "unpersisted changes" error. Such a record now has only its flag read
336
+ under the lock; it flips the row's value, and its changes save with the flip. A record without
337
+ unsaved changes is still reloaded under the lock. (#124)
338
+ - **Encryptable:** `type: :datetime` stores a frozen or non-UTC `Time` correctly and never
339
+ modifies it. `Time#utc` converted the caller's value in place, and a frozen `Time` was
340
+ silently saved as `NULL`. (#125)
341
+ - **Auditable:** recording a tracked datetime no longer converts the record's (the caller's)
342
+ `Time` to UTC in place, and a frozen `Time` no longer makes the save raise `FrozenError`.
343
+ (#125)
344
+ - **CursorPaginatable:** minting a cursor no longer converts the page-boundary record's
345
+ timestamp to UTC in place. (#125)
346
+ - **CursorPaginatable:** a cursor carrying a timestamp outside years 0001..9999 is an
347
+ `InvalidCursor` (400). It used to reach the WHERE clause and raise `DatetimeFieldOverflow` on
348
+ PostgreSQL (a 500), and SQLite compared it as text. (#125)
349
+ - **Filterable:** date/time comparison operands outside `0001-01-01`..`9999-12-31` (the portable
350
+ SQL range) never reach the database. They are answered per operator, like out-of-range
351
+ numbers: `?happened_at_lt=300000-01-01` is every non-NULL row, `_gt` none, and in equality /
352
+ `in` / `not` / `not_in` such a value equals nothing. They used to raise
353
+ `DatetimeFieldOverflow` on PostgreSQL (a 500), and SQLite answered year-10000 comparisons
354
+ backwards. (#125)
355
+
356
+ ### Internal
357
+ - New support modules: `Support::Callable`, `Support::SlugSources`,
358
+ `Support::AssociationScope`; gem-level singletons moved to `lib/concerns_on_rails/core.rb`. A
359
+ real-Rails-app subprocess spec covers filter wiring for direct requires.
360
+ - Dev dependencies: Rails 8.1.4, permittable 0.10.0; the permittable compatibility suite accepts
361
+ both the pre-0.9 and 0.9+ validation messages (the gemspec still allows `~> 0.1`). (#121, #126)
362
+ - New support modules: `Support::Locking` (lock_version mirroring, row-locked reads),
363
+ `Support::TimeValue` (verb time arguments, virtual datetimes, the portable year range),
364
+ `Support::GeneratedValues` and `Support::EncryptedLookup` (values generated in
365
+ `before_create`, equality lookups over encrypted columns). (#123–#125)
366
+ - Documented limits: see `docs/concerns/sequenceable.md` (zones across STI leaves under an
367
+ abstract declarer; re-declaring an owner after its subclasses) and
368
+ `docs/concerns/stateable.md` (a subclass stub outlives a later parent re-declaration).
369
+ `unlock_access!`'s unconditional write is left for a follow-up. Also documented: a
370
+ CounterCacheable parent destroyed later in the same rolled-back transaction keeps its
371
+ in-memory mirror (reload it); Encryptable `find_by_<field>` does not apply the field's Rails
372
+ `normalizes`; on Rails 7.2+ an Encryptable `:datetime` skip list must precede the `encryptable`
373
+ line or be followed by a re-declaration (`docs/concerns/encryptable.md`).
374
+
3
375
  ## 1.30.0 (2026-09-27)
4
376
 
5
377
  The fixes from the 2026-09-23 whole-gem audit of 1.29.0: 37 bugs (8 HIGH) across six PRs