concerns_on_rails 1.25.0 → 1.27.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 423bbd556d23280595c6a456d16a18255ada3898f88169831005526795f936fc
4
- data.tar.gz: 96a50269d776642a46534f50864730ea54fc2f82d458ab3e0ef11424f742fc29
3
+ metadata.gz: b06bf25720ff6c5c3a243efdb7b0d575351d50b8a9f33ae4778c1354816075bb
4
+ data.tar.gz: 01dc0ff521bee7b5c74c9f30ddee414932441d2ceff84a15af1d9ae8e6d30ccc
5
5
  SHA512:
6
- metadata.gz: 6c0b871a10e781fdd6d5fb9464eb8e245783adb2b3f20bb7067758934655ae8c82d0eb082d121e198e7a7a1040db60b467600bb8b8307a9484d034c1ebceecab
7
- data.tar.gz: e550786d60b36e5d44239d59a283776f4debba254a43eacb9b2141112a94a0af8d2b53feeebc4ee3e6528f0973117abfa89a1e04ae4033e7f7058e2e788e5d5a
6
+ metadata.gz: 37a533852c90cc1876519bd8b52fd07f165bfcfb02efca47bd1ed9d3ff86de94457755d57f9b986d244674a29992cead1b43698de7e3480e92276a0b9c9f1eda
7
+ data.tar.gz: 3651f6a175bf1a1e203bbe8353a9fc8b7a2c6457dd19f73e89e31fa108a8e238f36a7004aeffface605800b2838f6bb935694ef823a65dc0be943763bfd31293
data/CHANGELOG.md CHANGED
@@ -1,5 +1,138 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.27.0 (2026-08-29)
4
+
5
+ Scope-name collisions finally have an escape hatch on the eight concerns whose
6
+ generated scope names can collide (Activatable, Expirable, Lockable, Stateable
7
+ and Anonymizable already had it; Publishable, SoftDeletable and Schedulable
8
+ join them here), and the 1.22 batch-operation contract reaches five more
9
+ concerns. No new columns, migrations or dependencies. 1257 examples, 0
10
+ failures.
11
+
12
+ ### Added
13
+ - **Models::Publishable / SoftDeletable / Schedulable**: `prefix:`/`suffix:` on
14
+ `publishable_by` / `soft_deletable_by` / `schedulable_by` rename the generated
15
+ scopes, so a model can include SoftDeletable (`.active`) alongside Activatable
16
+ or Expirable (also `.active`) without one silently clobbering the other. With
17
+ no affix passed the scope names, default scopes and emitted SQL are unchanged.
18
+ `prefix: true` (use the configured field name), previously honoured only by
19
+ Stateable, now works on every affixing concern.
20
+ - **Models::Publishable**: `publish_all` / `unpublish_all`. `publish_all` targets
21
+ every not-currently-published row — *including scheduled ones*, whose future
22
+ timestamp it overwrites; chain the draft scope (`Post.draft.publish_all`) to
23
+ narrow it, which composes because the predicate is built against the current
24
+ relation rather than routed through the field-unscoping `unpublished` scope.
25
+ The flip side: on a model with `default_scope: true` the relation is already
26
+ narrowed to published rows, so a bare `Post.publish_all` matches nothing —
27
+ chain `.draft` or `.unpublished` (both unscope the column themselves). Both
28
+ verbs respect the relation, return an Integer count, and run in a
29
+ transaction.
30
+ - **Models::Expirable**: `expire_all(time = Time.zone.now)`.
31
+ - **Models::Activatable**: `activate_all` / `deactivate_all`.
32
+ - **Models::Lockable**: `unlock_expired` — clears `locked_at` and zeroes the
33
+ attempts counter on every row whose `unlock_in` window has elapsed, mirroring
34
+ `unlock_access!`. Returns 0 without querying when `unlock_in` is nil.
35
+ - **Models::Stateable**: `transition_all(event)` — runs one declared transition
36
+ across the relation, skipping (not failing) records the guard rejects.
37
+ Deliberately has no single-UPDATE fast path: the per-record path runs
38
+ validations through `update!` and a bulk UPDATE would skip them.
39
+
40
+ ### Notes
41
+ Validation semantics of the new batch verbs (`publish_all`, `unpublish_all`,
42
+ `expire_all`, `activate_all`, `deactivate_all`, `unlock_expired`): each
43
+ collapses to a **single `UPDATE`** — one SQL statement for the whole batch —
44
+ only when the host model declares **no validations** (and has overridden
45
+ none of the concern's hooks/bang methods; see the per-concern docs for the
46
+ exact method list). "No validations" means neither `validates` /
47
+ `validates_with` **nor** a custom `validate :method` / `validate do … end` —
48
+ the latter registers only a validate callback and leaves `validators` empty,
49
+ so it is detected through `_validate_callbacks` rather than `validators`.
50
+ It also means **no association carrying the default autosave validation**: a
51
+ bare `has_many`/`has_one` registers a `validate_associated_records_*` callback,
52
+ so in practice most models with associations take the streaming per-record
53
+ path. That is deliberate — the gate errs toward the path that honours the
54
+ rollback contract — but it means the single-`UPDATE` optimisation applies to
55
+ simple models, not to every model that merely omits `validates`.
56
+ `unlock_expired` is exempt from the validations check because
57
+ `unlock_access!` writes via `update_columns`, which always skips validations,
58
+ so its two paths are already equivalent. On a model that declares any
59
+ validation, five of these verbs (`publish_all`, `unpublish_all`, `expire_all`,
60
+ `activate_all`, `deactivate_all`) instead stream the relation record-by-record
61
+ inside a transaction, calling the same guarded bang/update method a single
62
+ record would use; a record that fails to save raises
63
+ `ActiveRecord::RecordNotSaved` and rolls the **entire batch** back — nothing
64
+ partially commits.
65
+
66
+ The single-`UPDATE` path writes `updated_at` (`updated_on` too, when present)
67
+ alongside the concern's own column whenever the model has that column and
68
+ `record_timestamps` is on, so the fast and slow paths agree — the same thing
69
+ Rails' `touch_all` and `update_counters(touch:)` do. `unlock_expired` is
70
+ exempt here as well: it mirrors `unlock_access!`, which writes via
71
+ `update_columns` and deliberately does not touch timestamps.
72
+
73
+ `transition_all` also always takes the per-record path, regardless of
74
+ validators — for the same underlying reason (validations must run) — but its
75
+ failure mode is different, not the same: the per-record path calls the
76
+ guarded `<event>!`, which calls `update!`, and `update!` raises
77
+ `ActiveRecord::RecordInvalid` **directly** on a validation failure. That
78
+ exception propagates straight out of the batch loop, never reaching the
79
+ `RecordNotSaved` branch the other five verbs use. Code that rescues
80
+ `RecordNotSaved` around a `transition_all` call will not catch a failed row —
81
+ rescue `RecordInvalid` there instead.
82
+
83
+ One residual, deliberate divergence survives on the fast path: like every
84
+ `*_all` method in Rails, the single-UPDATE path does not fire host-defined
85
+ `before_save`/`after_save` callbacks (only the concern's own
86
+ `before_*`/`after_*` lifecycle hooks are checked when deciding whether the
87
+ fast path applies at all). If your model relies on `before_save`/`after_save`
88
+ for side effects, either add a validation (forcing the slow, per-record path)
89
+ or call the bang method in a loop.
90
+
91
+ ### Internal
92
+ - New `Support::Affix` (affixed-name computation, `prefix: true` normalization,
93
+ and the guarded scope capture/retirement used by the three newly affixable
94
+ concerns) replaces six duplicated implementations across Activatable,
95
+ Expirable, Lockable, Anonymizable, Stateable and Storable.
96
+ - New `Support::BatchOps`: the whole fast-path safety decision
97
+ (`fast_path?` = hooks/bang methods unoverridden AND no declared validations,
98
+ detected through both `validators` and `_validate_callbacks`), the
99
+ ownership-only half (`unoverridden?`, for the two concerns whose per-record
100
+ path writes via `update_column(s)` and so needs no validations gate), the
101
+ `updated_at`/`updated_on` bulk-write payload helper (`with_timestamps`), and
102
+ the transactional `find_each` runner. SoftDeletable's `soft_delete_all` /
103
+ `restore_all` now route through it, so the contract has one definition.
104
+ - Retiring a default-named scope is guarded three ways — the name must have been
105
+ recorded by the concern, be owned by the class's own singleton, and still be
106
+ the exact method captured — so a model's own override survives and a parent's
107
+ scopes are never removed from a subclass (that case raises with a pointer to
108
+ the parent).
109
+
110
+ ## 1.26.0 (2026-08-24)
111
+
112
+ A fixes-and-performance round from a full audit of the 25 model concerns: one privacy bug (Anonymizable left Encryptable blind-index fingerprints queryable after erasure), four silent-misbehavior fixes, five query-count reductions, and three hardening changes. No new concerns. 1173 examples, 0 failures.
113
+
114
+ ### Fixed
115
+ - **Models::Anonymizable × Encryptable**: `anonymize!` writes via `update_columns`, which skips `before_save` — so Encryptable's blind-index refresh never ran and the `<field>_bidx` column kept the deterministic fingerprint of the ERASED value: `find_by_<field>(old_pii)` still resolved the record after erasure. The erasure payload now rewrites the blind-index column in the same single UPDATE (the fingerprint of the anonymized value; nil when the strategy nullifies), so the erased value stops resolving.
116
+ - **Models::Publishable**: `publish_at!(1.day.from_now)` on a BOOLEAN publishable column now raises `ArgumentError` — the Time used to cast to `true` and silently publish NOW instead of scheduling (a boolean column cannot represent a future publish; use `publish!`/`unpublish!` or a datetime column).
117
+ - **Models::Monetizable**: `subunit_to_unit:` is coerced to Integer at the macro. A String like `"100"` passed the old `.to_i.positive?` validation but was stored raw — the writer's `BigDecimal * "100"` raised TypeError (swallowed to nil by the form-garbage rescue, silently nil-ing every assignment) and the reader's division raised outright.
118
+ - **Models::Taggable**: input containing the delimiter now splits into multiple tags eagerly and identically everywhere — `add_tags("a,b")` adds "a" and "b"; `tag_list=`, `remove_tags`, and `tagged_with?` (AND semantics, mirroring the class-level scope) agree. A tag can never survive with the delimiter inside it (the column format cannot escape it), so pre-1.26 such input silently split on the NEXT normalize pass instead.
119
+ - **Models::Sortable**: `sortable_by` raises `ArgumentError` on unknown trailing options (a typo'd `ad_new_at:` used to vanish into `**field_options`), on a multi-pair Hash (only the first pair was read), and on an invalid direction (previously coerced to `:asc` silently — reordering the whole default scope without a whisper).
120
+
121
+ ### Changed
122
+ - **Models::Stateable**: `stateable_by` validates its options — unknown keys raise (previously ignored silently). New `lock: true` option: guarded `<event>!` transitions take a row lock (`SELECT ... FOR UPDATE`) and re-check the guard against the fresh row, closing the check-then-write race where two concurrent transitions both passed the in-memory guard. Off by default; requires a clean record (`with_lock` reloads) and costs a SELECT per transition.
123
+ - **Models::Sluggable**: `sluggable_by ..., scope:` accepts an association name (`scope: :account`) — friendly_id resolves association scopes itself, but ColumnGuard rejected them as missing columns. Column scopes (`scope: :account_id`) validate exactly as before.
124
+ - **Models::SoftDeletable**: `deleted_within` emits a table-qualified Arel predicate, so the scope stays unambiguous inside joins against tables sharing the column name.
125
+
126
+ ### Performance
127
+ - **Models::CounterCacheable**: counter adjustments are grouped per (parent class, parent id) and flushed as ONE `update_counters` UPDATE — a model with sibling counters on the same parent (`comments_count` + `approved_comments_count`) used to issue one statement per rule per save. A reparent stays at one UPDATE per parent, both counters batched.
128
+ - **Models::Anonymizable**: `anonymize_all!` streams in PK batches (`find_each`) instead of loading the whole relation, filters already-stamped rows DB-side, and skips the per-record `reload` (batch instances are discarded) — two queries saved per record on large erasure jobs.
129
+ - **Models::SoftDeletable**: the `soft_delete_all` / `restore_all` slow paths stream via `find_each` with DB-side filtering instead of `to_a`-loading the relation (memory-bounded; the Integer-count contract, rollback semantics, and the single-UPDATE fast path are unchanged).
130
+ - **Models::Sequenceable**: creates no longer run the `exists?` probe after computing MAX+1 — within one consistent read MAX+1 cannot be taken, so the probe re-verified a tautology on EVERY create (and could not close the concurrent-insert race anyway; the scoped unique index does, per the module docs). One query saved per create.
131
+ - **Models::Taggable**: `all_tags` dedupes DB-side (`where.not(nil).distinct.pluck`), so identical tag strings ship over the wire once instead of once per row.
132
+
133
+ ### Internal
134
+ - Regression specs for every fix — the Anonymizable blind-index pair doubles as the previously-missing Encryptable-composition coverage; CounterCacheable gained SQL statement-count specs; Sequenceable gained a no-probe query-count spec. Sluggable's `class_methods do` converted to a real `ClassMethods` module (the Stateable precedent).
135
+
3
136
  ## 1.25.0 (2026-08-16)
4
137
 
5
138
  One new controller concern — Permittable, typed/validated params contracts with a boot-time schema-drift guard — developed here and shipped as the standalone [`permittable` gem](https://rubygems.org/gems/permittable) (new runtime dependency; `ConcernsOnRails::Controllers::Permittable` is an alias). 1160 examples, 0 failures.
data/README.md CHANGED
@@ -6,13 +6,17 @@
6
6
  One `include`, one declarative macro — done.
7
7
 
8
8
  [![Gem Version](https://img.shields.io/gem/v/concerns_on_rails?logo=rubygems&logoColor=white&color=CC342D)](https://rubygems.org/gems/concerns_on_rails)
9
- [![Downloads](https://img.shields.io/gem/dt/concerns_on_rails?color=1f6feb)](https://rubygems.org/gems/concerns_on_rails)
9
+ [![Total Downloads](https://img.shields.io/gem/dt/concerns_on_rails?color=1f6feb&label=downloads)](https://rubygems.org/gems/concerns_on_rails)
10
+ [![Latest Version Downloads](https://img.shields.io/gem/dtv/concerns_on_rails?color=8250df&label=latest%20version)](https://rubygems.org/gems/concerns_on_rails/versions)
10
11
  [![CI](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml/badge.svg)](https://github.com/VSN2015/concerns_on_rails/actions/workflows/ci.yml)
12
+ [![Docs](https://img.shields.io/badge/docs-vsn2015.github.io-6f42c1?logo=readthedocs&logoColor=white)](https://vsn2015.github.io/concerns_on_rails)
11
13
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
12
14
  [![Rails](https://img.shields.io/badge/rails-5.0--8.x-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
13
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](#-license)
14
16
 
15
- 🧩 **26 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated**
17
+ ### [📖 **Documentation**](https://vsn2015.github.io/concerns_on_rails) &nbsp;·&nbsp; [💎 **RubyGems**](https://rubygems.org/gems/concerns_on_rails) &nbsp;·&nbsp; [📝 **Changelog**](CHANGELOG.md) &nbsp;·&nbsp; [🐛 **Issues**](https://github.com/VSN2015/concerns_on_rails/issues)
18
+
19
+ 🧩 **26 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated** &nbsp;·&nbsp; 🧪 **1,160 specs**
16
20
 
17
21
  </div>
18
22
 
@@ -32,64 +36,108 @@ end
32
36
  Article.published.without_deleted.find("hello-world")
33
37
  ```
34
38
 
39
+ > 📖 **Prefer browsable docs?** Every concern has its own searchable, deep-linkable page
40
+ > (dark mode included) at **[vsn2015.github.io/concerns_on_rails](https://vsn2015.github.io/concerns_on_rails)** —
41
+ > same content as this README, one page per concern. For install stats, every released
42
+ > version, and the live download counter, see the gem page:
43
+ > **[rubygems.org/gems/concerns_on_rails](https://rubygems.org/gems/concerns_on_rails)**.
44
+
35
45
  ---
36
46
 
37
47
  ## 📚 Table of Contents
38
48
 
39
- [Why this gem?](#-why-this-gem) &nbsp;·&nbsp; [Installation](#-installation) &nbsp;·&nbsp; [Compatibility](#-compatibility) &nbsp;·&nbsp; [Quick Start](#-quick-start) &nbsp;·&nbsp; [Module paths](#-module-paths--namespacing) &nbsp;·&nbsp; [Development](#-development) &nbsp;·&nbsp; [Contributing](#-contributing) &nbsp;·&nbsp; [License](#-license)
49
+ [Find your concern](#-find-your-concern) &nbsp;·&nbsp; [Why this gem?](#-why-this-gem) &nbsp;·&nbsp; [Installation](#-installation) &nbsp;·&nbsp; [Compatibility](#-compatibility) &nbsp;·&nbsp; [Quick Start](#-quick-start) &nbsp;·&nbsp; [Module paths](#-module-paths--namespacing) &nbsp;·&nbsp; [AI assistants](#-using-this-gem-with-ai-assistants) &nbsp;·&nbsp; [Development](#-development) &nbsp;·&nbsp; [Contributing](#-contributing) &nbsp;·&nbsp; [License](#-license)
50
+
51
+ Every concern below links to its section in this README **and** to its standalone page on the [docs site](https://vsn2015.github.io/concerns_on_rails) (📖).
40
52
 
41
53
  ### 🧱 Model concerns
42
54
 
43
- | Concern | What it does |
44
- |---------|--------------|
45
- | [📝 Sluggable](#-sluggable) | URL-friendly slugs |
46
- | [🔢 Sortable](#-sortable) | List ordering via `acts_as_list` |
47
- | [📤 Publishable](#-publishable) | `published_at` timestamp publishing |
48
- | [❌ SoftDeletable](#-softdeletable) | Soft delete with scopes &amp; hooks |
49
- | [🔐 Hashable](#-hashable) | Auto-generate tokens / UUIDs / codes |
50
- | [🗓️ Schedulable](#-schedulable) | `starts_at` / `ends_at` time windows |
51
- | [⏳ Expirable](#-expirable) | Single-timestamp expiry |
52
- | [✨ Normalizable](#-normalizable) | Attribute normalization (`:email`, `:phone`, …) |
53
- | [🔍 Searchable](#-searchable) | LIKE / ILIKE search across columns |
54
- | [✅ Activatable](#-activatable) | Boolean active / inactive toggle |
55
- | [🔑 Tokenizable](#-tokenizable) | Security tokens with timing-safe lookup |
56
- | [🧾 Sequenceable](#-sequenceable) | Ordered, human-friendly reference numbers |
57
- | [🔄 Stateable](#-stateable) | Lightweight string-backed state machine |
58
- | [🏠 Addressable](#-addressable) | Postal address normalization + validation |
59
- | [🏷️ Taggable](#-taggable) | Lightweight tagging over a single column |
60
- | [🧼 Sanitizable](#-sanitizable) | Opt-in HTML sanitization (XSS defense) |
61
- | [🙈 Maskable](#-maskable) | Non-destructive display masking |
62
- | [💰 Monetizable](#-monetizable) | Integer-cents money columns (BigDecimal) |
63
- | [📜 Auditable](#-auditable) | Single-column change history ("paper_trail-lite") |
64
- | [🔐 Lockable](#-lockable) | Failed-attempt tracking + account lockout |
65
- | [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases |
66
- | [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
67
- | [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
68
- | [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups |
69
- | [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies |
70
- | [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") |
55
+ | Concern | What it does | Docs |
56
+ |---------|--------------|:----:|
57
+ | [📝 Sluggable](#-sluggable) | URL-friendly slugs | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sluggable) |
58
+ | [🔢 Sortable](#-sortable) | List ordering via `acts_as_list` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sortable) |
59
+ | [📤 Publishable](#-publishable) | `published_at` timestamp publishing | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/publishable) |
60
+ | [❌ SoftDeletable](#-softdeletable) | Soft delete with scopes &amp; hooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/soft-deletable) |
61
+ | [🔐 Hashable](#-hashable) | Auto-generate tokens / UUIDs / codes | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/hashable) |
62
+ | [🗓️ Schedulable](#-schedulable) | `starts_at` / `ends_at` time windows | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/schedulable) |
63
+ | [⏳ Expirable](#-expirable) | Single-timestamp expiry | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/expirable) |
64
+ | [✨ Normalizable](#-normalizable) | Attribute normalization (`:email`, `:phone`, …) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/normalizable) |
65
+ | [🔍 Searchable](#-searchable) | LIKE / ILIKE search across columns | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/searchable) |
66
+ | [✅ Activatable](#-activatable) | Boolean active / inactive toggle | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/activatable) |
67
+ | [🔑 Tokenizable](#-tokenizable) | Security tokens with timing-safe lookup | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/tokenizable) |
68
+ | [🧾 Sequenceable](#-sequenceable) | Ordered, human-friendly reference numbers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sequenceable) |
69
+ | [🔄 Stateable](#-stateable) | Lightweight string-backed state machine | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/stateable) |
70
+ | [🏠 Addressable](#-addressable) | Postal address normalization + validation | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/addressable) |
71
+ | [🏷️ Taggable](#-taggable) | Lightweight tagging over a single column | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/taggable) |
72
+ | [🧼 Sanitizable](#-sanitizable) | Opt-in HTML sanitization (XSS defense) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sanitizable) |
73
+ | [🙈 Maskable](#-maskable) | Non-destructive display masking | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/maskable) |
74
+ | [💰 Monetizable](#-monetizable) | Integer-cents money columns (BigDecimal) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/monetizable) |
75
+ | [📜 Auditable](#-auditable) | Single-column change history ("paper_trail-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/auditable) |
76
+ | [🔐 Lockable](#-lockable) | Failed-attempt tracking + account lockout | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/lockable) |
77
+ | [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/aliasable) |
78
+ | [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/storable) |
79
+ | [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/counter-cacheable) |
80
+ | [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/encryptable) |
81
+ | [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/anonymizable) |
82
+ | [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/duplicable) |
71
83
 
72
84
  ### 🎮 Controller concerns
73
85
 
74
- | Concern | What it does |
75
- |---------|--------------|
76
- | [📄 Paginatable](#-paginatable) | Offset pagination with headers |
77
- | [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers |
78
- | [🔎 Filterable](#-filterable) | Declarative URL-param filters |
79
- | [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list |
80
- | [📦 Respondable](#-respondable) | Standardized JSON envelopes |
81
- | [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers |
82
- | [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets |
83
- | [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL |
84
- | [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` |
85
- | [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate |
86
- | [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) |
87
- | [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie |
88
- | [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay |
89
- | [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks |
90
- | [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 |
91
- | [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` |
92
- | [🛂 Permittable](#-permittable) | Typed, validated params contracts + schema-drift guard |
86
+ | Concern | What it does | Docs |
87
+ |---------|--------------|:----:|
88
+ | [📄 Paginatable](#-paginatable) | Offset pagination with headers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/paginatable) |
89
+ | [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cursor-paginatable) |
90
+ | [🔎 Filterable](#-filterable) | Declarative URL-param filters | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/filterable) |
91
+ | [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/sortable-controller) |
92
+ | [📦 Respondable](#-respondable) | Standardized JSON envelopes | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/respondable) |
93
+ | [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/error-handleable) |
94
+ | [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/includable) |
95
+ | [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/secure-headable) |
96
+ | [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/localizable) |
97
+ | [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/authorizable) |
98
+ | [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/throttleable) |
99
+ | [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/timezoneable) |
100
+ | [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/idempotentable) |
101
+ | [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/webhook-verifiable) |
102
+ | [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/deprecatable) |
103
+ | [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/cacheable) |
104
+ | [🛂 Permittable](#-permittable) | Typed, validated params contracts + schema-drift guard | [📖](https://vsn2015.github.io/concerns_on_rails/#/c/permittable) |
105
+
106
+ ---
107
+
108
+ ## 🧭 Find your concern
109
+
110
+ Forty-three concerns is a lot of menu. Start from the problem instead:
111
+
112
+ | "I need to…" | Reach for |
113
+ |--------------|-----------|
114
+ | Give records pretty URLs — `/posts/hello-world`, not `/posts/42` | [📝 Sluggable](#-sluggable) |
115
+ | Let users trash records, then restore them | [❌ SoftDeletable](#-softdeletable) |
116
+ | Schedule a post to go live Friday at 9am | [📤 Publishable](#-publishable) |
117
+ | Support drag-and-drop reordering | [🔢 Sortable](#-sortable) |
118
+ | Issue API keys / invite codes with timing-safe lookup | [🔑 Tokenizable](#-tokenizable) |
119
+ | Number invoices like `INV-2026-00042`, per account, resetting yearly | [🧾 Sequenceable](#-sequenceable) |
120
+ | Add a small state machine without the AASM dependency | [🔄 Stateable](#-stateable) |
121
+ | Encrypt SSNs at rest and still `find_by` them | [🔏 Encryptable](#-encryptable) |
122
+ | Handle a GDPR "delete my data" request in one call | [🕵️ Anonymizable](#-anonymizable) |
123
+ | Know who changed which field, and when | [📜 Auditable](#-auditable) |
124
+ | Lock accounts after 5 failed logins | [🔐 Lockable](#-lockable) |
125
+ | Keep typed, defaulted settings in one JSON column | [⚙️ Storable](#-storable) |
126
+ | Maintain `approved_comments_count` next to `comments_count` | [🧮 CounterCacheable](#-countercacheable) |
127
+ | Ship a "duplicate this invoice" button (line items included) | [🧬 Duplicable](#-duplicable) |
128
+ | Handle money without ever touching a Float | [💰 Monetizable](#-monetizable) |
129
+ | Paginate a JSON index — page numbers or infinite scroll | [📄 Paginatable](#-paginatable) / [🧭 CursorPaginatable](#-cursorpaginatable) |
130
+ | Turn `?status=published&sort=title` into scopes safely | [🔎 Filterable](#-filterable) + [↕️ Sortable](#-sortable-controller) |
131
+ | Render one consistent JSON envelope, errors included | [📦 Respondable](#-respondable) + [🛟 ErrorHandleable](#-errorhandleable) |
132
+ | Rate-limit login attempts per IP | [🚦 Throttleable](#-throttleable) |
133
+ | Make payment retries safe (Stripe-style `Idempotency-Key`) | [🔁 Idempotentable](#-idempotentable) |
134
+ | Verify Stripe / GitHub / Shopify webhook signatures | [🪝 WebhookVerifiable](#-webhookverifiable) |
135
+ | Retire `/api/v1` with proper `Deprecation` / `Sunset` headers | [🌅 Deprecatable](#-deprecatable) |
136
+ | Serve ETags + 304s so clients stop re-downloading JSON | [🗄️ Cacheable](#-cacheable) |
137
+ | Get strong params that also cast, bound-check, and default | [🛂 Permittable](#-permittable) |
138
+
139
+ Still browsing? The [docs site](https://vsn2015.github.io/concerns_on_rails) has **instant search**
140
+ across all 43 concerns — press <kbd>/</kbd> and type.
93
141
 
94
142
  ---
95
143
 
@@ -98,8 +146,10 @@ Article.published.without_deleted.find("hello-world")
98
146
  - **Twenty-six model concerns + sixteen controller concerns**, all production-ready
99
147
  - **One include, one macro** — no boilerplate, no glue code
100
148
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
101
- - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
149
+ - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early — with a ready-to-paste `rails generate migration` hint when it doesn't
102
150
  - **Composable** — concerns are independent; mix and match per model
151
+ - **Tested like an app, not a snippet** — **1,160 RSpec examples** run against a real database on every CI build
152
+ - **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
103
153
 
104
154
  ---
105
155
 
@@ -108,7 +158,7 @@ Article.published.without_deleted.find("hello-world")
108
158
  Add to your application's `Gemfile`:
109
159
 
110
160
  ```ruby
111
- gem "concerns_on_rails", "~> 1.25"
161
+ gem "concerns_on_rails", "~> 1.26"
112
162
  ```
113
163
 
114
164
  Or pull the latest from GitHub:
@@ -308,6 +358,58 @@ publishable_by :published_at, default_scope: true
308
358
  # Article.unscoped reaches everything
309
359
  ```
310
360
 
361
+ **Bulk operations**
362
+
363
+ ```ruby
364
+ Post.draft.publish_all # publishes every not-currently-published post; returns the count
365
+ Post.published.unpublish_all # unpublishes every currently-published post; returns the count
366
+ ```
367
+
368
+ Both respect the current relation, return an Integer count, and run in a transaction — a
369
+ record that fails to save raises `ActiveRecord::RecordNotSaved` and rolls the whole batch
370
+ back. With no overridden `before_publish`/`after_publish`/`before_unpublish`/`after_unpublish`/
371
+ `publish!`/`unpublish!` and no validations on the model — neither `validates`/`validates_with`,
372
+ a custom `validate :method`, nor an association's autosave validation (a bare `has_many`
373
+ registers one, so most models with associations take the streaming path) — both collapse to a
374
+ single `UPDATE`, which bumps `updated_at`
375
+ exactly as the per-record path does; otherwise they stream per record through
376
+ `publish!`/`unpublish!` so validations still run.
377
+
378
+ `publish_all` targets every not-currently-published row — **including scheduled ones**, whose
379
+ future `published_at` it overwrites — so chain `.draft` (`Post.draft.publish_all`) to exclude
380
+ them. Its predicate composes with your own constraint on the publish column, so that chain
381
+ really does leave scheduled rows alone. The flip side of composing: with `default_scope: true`
382
+ the relation is already narrowed to published rows, so a bare `Post.publish_all` matches
383
+ nothing — chain `.draft` or `.unpublished` first (both unscope the column themselves, so the
384
+ chain resolves to the rows you mean).
385
+
386
+ **Scope-name collisions**
387
+
388
+ ```ruby
389
+ publishable_by :published_at, prefix: :article # => Article.article_published / .article_draft
390
+ publishable_by :published_at, suffix: :posts # => Article.published_posts / .draft_posts
391
+ publishable_by :published_at, prefix: true # => Article.published_at_published / ...
392
+ ```
393
+
394
+ `prefix:`/`suffix:` rename every scope `publishable_by` generates, so a model can include
395
+ Publishable alongside another concern that would otherwise generate a same-named scope
396
+ (SoftDeletable and Activatable also define `.active`, for instance) without one clobbering
397
+ the other. With no affix passed, scope names, the optional default scope, and the emitted
398
+ SQL are all unchanged.
399
+
400
+ `prefix:`/`suffix:` mean three different things across the gem, depending on the concern:
401
+ - **Scope-name affix** (renames generated scopes) — Activatable, Expirable, Lockable,
402
+ Stateable, Anonymizable, Publishable, SoftDeletable, Schedulable.
403
+ - **Accessor-name affix** (renames generated reader/writer methods) — Storable.
404
+ - **A literal string prepended to the generated value itself** — Sequenceable's `prefix:`
405
+ (e.g. `"INV-"`), unrelated to scope/method naming.
406
+
407
+ Passing `true` for a scope- or accessor-name affix means "use the configured field name"
408
+ (`publishable_by :published_at, prefix: true` → `published_at_published`/`published_at_draft`).
409
+
410
+ Unrelated to all three: Searchable's `match: :prefix` is a LIKE-match mode (`term%`), not a
411
+ naming affix.
412
+
311
413
  **Notes**
312
414
  - "Published" means `published_at` is set **and** in the past — so future-dated posts stay unpublished until their time arrives.
313
415
  - No `default_scope` is added by default; chain `.published` explicitly (or opt in with `default_scope: true`).
@@ -362,6 +464,21 @@ collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-del
362
464
  predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
363
465
  relation — purge trash with `User.soft_deleted.delete_all` instead.
364
466
 
467
+ **Scope-name collisions**
468
+
469
+ ```ruby
470
+ soft_deletable_by :deleted_at, prefix: :account # => .account_active / .account_soft_deleted / ...
471
+ soft_deletable_by :deleted_at, suffix: :records # => .active_records / .soft_deleted_records / ...
472
+ soft_deletable_by :deleted_at, prefix: true # => .deleted_at_active / ...
473
+ ```
474
+
475
+ `prefix:`/`suffix:` rename every scope `soft_deletable_by` generates (`active`,
476
+ `without_deleted`, `soft_deleted`, `only_deleted`, `with_deleted`, `deleted_within`), so a
477
+ model can combine SoftDeletable with another concern that also defines `.active` (Activatable,
478
+ Expirable) without a collision. `prefix: true` uses the configured field name. With no affix
479
+ passed, scope names, the default scope, and the emitted SQL are unchanged. See the
480
+ Publishable section above for how `prefix:`/`suffix:` differ across the gem.
481
+
365
482
  **Lifecycle hooks** — override these methods on the model:
366
483
 
367
484
  ```ruby
@@ -462,6 +579,18 @@ promo.reschedule!(starts_at: 1.day.from_now,
462
579
  ends_at: 2.days.from_now)
463
580
  ```
464
581
 
582
+ **Scope-name collisions**
583
+
584
+ ```ruby
585
+ schedulable_by prefix: :promo # => .promo_current / .promo_upcoming / .promo_expired / .promo_active_at
586
+ schedulable_by suffix: :window # => .current_window / .upcoming_window / .expired_window / .active_at_window
587
+ schedulable_by prefix: true # => .starts_at_current / ... (the configured starts_at/ends_at field)
588
+ ```
589
+
590
+ `prefix:`/`suffix:` rename every scope `schedulable_by` generates (`active_at`, `current`,
591
+ `upcoming`, `expired`). With no affix passed, scope names and the emitted SQL are unchanged.
592
+ See the Publishable section above for how `prefix:`/`suffix:` differ across the gem.
593
+
465
594
  **Notes**
466
595
  - Boundary semantics: **inclusive start, exclusive end** — active at exactly `starts_at`, not at exactly `ends_at`.
467
596
  - A `nil` end means "never expires"; a `nil` start means "not yet started".
@@ -502,6 +631,21 @@ token.extend_expiry!(by: 1.day) # pushes expiry forward
502
631
  - If `expires_at` is `nil` or in the past → new value is `now + by`
503
632
  - If `expires_at` is still in the future → `by` is added to the existing value
504
633
 
634
+ **Bulk operations**
635
+
636
+ ```ruby
637
+ ApiToken.expiring_within(1.day).expire_all # => 12
638
+ ```
639
+
640
+ `expire_all(time = Time.zone.now)` expires every currently-active record in the relation and
641
+ returns the Integer count, in a transaction. With `expire!` unoverridden and no validations on
642
+ the model — neither `validates`/`validates_with`, a custom `validate :method`, nor an
643
+ association's autosave validation (a bare `has_many` registers one, so most models with
644
+ associations take the streaming path) — it collapses
645
+ to a single `UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise it
646
+ streams per record through `expire!` so validations still run, and a record that fails to save
647
+ raises `ActiveRecord::RecordNotSaved` and rolls the whole batch back.
648
+
505
649
  **Custom field name**
506
650
 
507
651
  ```ruby
@@ -609,6 +753,23 @@ Subscription.active # WHERE active = TRUE
609
753
  Subscription.inactive # WHERE active = FALSE OR active IS NULL
610
754
  ```
611
755
 
756
+ **Bulk operations**
757
+
758
+ ```ruby
759
+ Subscription.inactive.activate_all # => 12
760
+ Subscription.active.deactivate_all # => 3
761
+ ```
762
+
763
+ Both target the relation, return an Integer count, and run in a transaction. With
764
+ `activate!`/`deactivate!` unoverridden and no validations on the model — neither
765
+ `validates`/`validates_with`, a custom `validate :method`, nor an association's autosave
766
+ validation (a bare `has_many` registers one, so most models with associations take the
767
+ streaming path) — they collapse to a single
768
+ `UPDATE`, which bumps `updated_at` exactly as the per-record path does; otherwise they stream
769
+ per record so validations still run, and a record that fails to save raises
770
+ `ActiveRecord::RecordNotSaved` and rolls the whole batch back. `toggle_active!`'s row lock has
771
+ no batch analogue.
772
+
612
773
  **Notes**
613
774
  - `NULL` is treated as inactive (same convention as most apps' "unset = off").
614
775
  - The configured column must exist; `activatable_by` raises `ArgumentError` otherwise.
@@ -755,6 +916,18 @@ article.may_publish? # => true (guard check without raising)
755
916
  article.transition_to!(:archived) # generic move to any declared state
756
917
  ```
757
918
 
919
+ **Bulk operations**
920
+
921
+ ```ruby
922
+ Article.draft.transition_all(:publish) # => 12 — runs :publish across every eligible row
923
+ ```
924
+
925
+ Returns the Integer count of records transitioned, running in a transaction; records the
926
+ guard rejects are skipped (not errors). Unlike every other batch verb in this gem,
927
+ `transition_all` has **no single-UPDATE fast path** — it always streams per record through
928
+ the guarded `<event>!` method, because that path runs validations via `update!` while a bulk
929
+ `update_all` would silently skip them.
930
+
758
931
  **Prefix / suffix** — avoid clashes when the state names overlap with other concerns or scopes:
759
932
 
760
933
  ```ruby
@@ -1044,10 +1217,21 @@ user.reset_failed_attempts! # call on successful login
1044
1217
  user.lock_access! # manual lock (hooks: before/after_lock)
1045
1218
  user.unlock_access! # manual unlock (hooks: before/after_unlock)
1046
1219
  User.locked / User.unlocked # expiry-aware scopes
1220
+
1221
+ User.unlock_expired # => 3 — unlocks every row whose unlock_in window has elapsed
1047
1222
  ```
1048
1223
 
1049
1224
  **Options**: `attempts:` (`:failed_attempts`, must be an integer column), `locked_at:` (`:locked_at`, datetime column), `max_attempts:` (`5`; `nil` = count but never auto-lock), `unlock_in:` (`nil` = locked until manual unlock; a duration makes the lock lapse by itself), `prefix:` / `suffix:` (affix the scope names).
1050
1225
 
1226
+ **Bulk operations**
1227
+
1228
+ `unlock_expired` clears `locked_at` and zeroes the attempts counter on every row whose
1229
+ `locked_at + unlock_in` has passed, exactly as `unlock_access!` does — returns the Integer
1230
+ count, runs in a transaction. Returns `0` without querying when `unlock_in` is `nil` (manual
1231
+ unlock only). Because `unlock_access!` persists via `update_columns` (which already skips
1232
+ validations), the single-`UPDATE` fast path and the per-record path are equivalent here —
1233
+ unlike Publishable/Expirable/Activatable, this one has no validators gate.
1234
+
1051
1235
  **Notes**
1052
1236
  - The increment is SQL-side (`COALESCE(attempts, 0) + 1` via `update_counters`), so concurrent failures never lose updates and a NULL counter needs no column default; a locked account stops counting.
1053
1237
  - Expiry is **lazy**: readers and scopes treat a stale lock as unlocked but never write. The column is cleared by the next `unlock_access!` or failed attempt (quietly there — no unlock hooks fire from a failed login).
@@ -1879,13 +2063,35 @@ Both forms reference the same module, so you can freely mix them.
1879
2063
 
1880
2064
  ---
1881
2065
 
2066
+ ## 🤖 Using this gem with AI assistants
2067
+
2068
+ Working with Claude, Cursor, Copilot, or another AI coding agent? The docs are AI-ready — every page
2069
+ is plain Markdown, fetchable without JavaScript:
2070
+
2071
+ - **[`llms.txt`](https://vsn2015.github.io/concerns_on_rails/llms.txt)** — a machine-readable index of
2072
+ every concern document, following the [llms.txt convention](https://llmstxt.org)
2073
+ - **[`llms-full.txt`](https://vsn2015.github.io/concerns_on_rails/llms-full.txt)** — all 43 concern docs
2074
+ concatenated into one ~400 KB plain-text file, for one-shot context loading
2075
+ - **Per-concern Markdown** — `https://vsn2015.github.io/concerns_on_rails/concerns/<slug>.md`
2076
+ (e.g. [`concerns/sluggable.md`](https://vsn2015.github.io/concerns_on_rails/concerns/sluggable.md))
2077
+ when your agent only needs one concern's docs
2078
+ - This README also ships **inside the packaged gem**, so an agent reading your bundle already has the
2079
+ full reference locally
2080
+
2081
+ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md` URL for focused context.
2082
+
2083
+ ---
2084
+
1882
2085
  ## 🛠️ Development
1883
2086
 
1884
2087
  ```sh
1885
2088
  bundle install # install dev dependencies
1886
- bundle exec rspec # run the test suite
2089
+ bundle exec rspec # run the test suite (1,245 examples)
1887
2090
  gem build concerns_on_rails.gemspec # build the gem
1888
- gem install ./concerns_on_rails-1.25.0.gem # install locally
2091
+ gem install ./concerns_on_rails-1.27.0.gem # install locally
2092
+
2093
+ # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2094
+ cd docs && python3 -m http.server 8000 # → http://localhost:8000
1889
2095
  ```
1890
2096
 
1891
2097
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -1894,7 +2100,25 @@ The test suite uses an in-memory SQLite database and a lightweight `FakeControll
1894
2100
 
1895
2101
  ## 🤝 Contributing
1896
2102
 
1897
- Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**. ⭐️ stars and 🍴 forks appreciated.
2103
+ Bug reports and pull requests are welcome at **[github.com/VSN2015/concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)**.
2104
+
2105
+ - 🐛 [Open an issue](https://github.com/VSN2015/concerns_on_rails/issues) — a failing spec is the fastest path to a fix
2106
+ - 🔀 Send a PR — run `bundle exec rspec` first; every concern keeps its spec under `spec/concerns/`
2107
+ - 📖 Docs count too — each concern's page lives in [`docs/concerns/`](docs/concerns) and redeploys automatically
2108
+ - ⭐️ If this gem saved you an afternoon of boilerplate, a star helps other devs find it
2109
+
2110
+ ---
2111
+
2112
+ ## 🔗 Links
2113
+
2114
+ | Resource | Where |
2115
+ |----------|-------|
2116
+ | 📖 Documentation site | [vsn2015.github.io/concerns_on_rails](https://vsn2015.github.io/concerns_on_rails) |
2117
+ | 💎 Gem page — live download count, all released versions | [rubygems.org/gems/concerns_on_rails](https://rubygems.org/gems/concerns_on_rails) |
2118
+ | 📝 Changelog | [CHANGELOG.md](CHANGELOG.md) |
2119
+ | 🤖 AI/LLM docs index (`llms.txt`) | [vsn2015.github.io/concerns_on_rails/llms.txt](https://vsn2015.github.io/concerns_on_rails/llms.txt) |
2120
+ | 🐛 Issue tracker | [github.com/VSN2015/concerns_on_rails/issues](https://github.com/VSN2015/concerns_on_rails/issues) |
2121
+ | 🛂 Permittable (extracted sibling gem) | [github.com/VSN2015/permittable](https://github.com/VSN2015/permittable) |
1898
2122
 
1899
2123
  ---
1900
2124