concerns_on_rails 1.18.0 → 1.20.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: 30c62bfbc26afd05fe7ed2e991add6fa180f3e71316ec638a045932e89bf2ce1
4
- data.tar.gz: af936258d441cf0206e120853f0a227967f663e577983628087defe2d60d7202
3
+ metadata.gz: 7eae55cba50d5c8a671187f6aba31e3b33e25eab1349054a45346400f64c70d1
4
+ data.tar.gz: e1369be961046f7f3926280083ee1f07fa027ad7c93d2161422f2b6dac687f57
5
5
  SHA512:
6
- metadata.gz: 359c8240a7d15da31c2a3236a69cb75b1cce57dc5fb80869214c64c05b3b7051d2a17ee17e73d5e9aec3321a53ca8a5de7ada2e73fa80f1f4da50e41fbfc6398
7
- data.tar.gz: 6b7388a71ceed19ee2517786f5bffdde6acb2e13506ce4ee00b983ff46293a9479aa91d01e2f6912e50a7eb1944f644534369215274fa2234fd889315e32e6f1
6
+ metadata.gz: 82eff9b0770e1d464418fb68ccf10b2564e48cf8305de882d644edef0134e1e07a90cec0f4a43cc6fdaa50945a663452e57bb0a4a20382be978b1a6204ad545d
7
+ data.tar.gz: ab30c390449a59a83b263393e2b0f1b66eb1d1e2a06d3e054354814f4ef2b916d5739bd6f4b3b48bca4b3772b1f75389cdaf5e1b6ef6014e55e8c3051deff52a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.20.0 (2026-06-18)
4
+
5
+ Two new concerns — the conditional counter cache the prior design panel held back as "too risky for one release" (1.19.0's notes), now shipped with the full update matrix specified and tested, plus the HTTP conditional-GET layer the API-oriented controller suite was missing. 933 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::CounterCacheable**: conditional, denormalized association counters ("counter_culture-lite"). Rails' native `counter_cache: true` counts *every* `belongs_to` child into one column — it can't keep an `approved_comments_count` beside a `comments_count`, and offers no drift repair. Declared on the CHILD, `counter_cacheable_by :post` (repeatable; rules reassigned-not-mutated so subclasses inherit; `count:` defaults to `"<table_name>_count"`, `if:` an optional Ruby condition, `touch:` to bump the parent's `updated_at`) keeps one or many parent columns in sync. `after_create`/`after_update`/`after_destroy` adjust via `update_counters` — a single SQL `COALESCE(col,0) ± 1`, atomic under concurrency — inside the record's own save transaction (a rolled-back save rolls back the counter). The update path resolves the full matrix: a foreign-key reparent decrements the old parent and increments the new one; a condition flip (`if:` result changed) increments/decrements in place; a simultaneous reparent + flip composes both; a no-op save writes nothing. The previous condition state is reconstructed by transiently restoring the changed attributes to their pre-save values (`saved_changes`, `previous_changes` on 5.0). `recount_counter_caches!(association = nil)` repairs drift / backfills portably (no adapter-specific SQL: unconditional counters via `group(fk).count`, conditional via a Ruby tally), rewriting every parent. Macro-time `ArgumentError` validation: the `belongs_to` must be declared first, must not be polymorphic, the parent counter column must exist (deferred when the parent class isn't yet loadable — load-order tolerant), `:if` callable, `:touch` boolean, unknown options rejected. Zero new runtime dependencies.
9
+ - **Controllers::Cacheable**: HTTP conditional GET + declarative `Cache-Control` ("fresh_when/stale?-lite" for JSON APIs), with method names chosen NOT to shadow Rails' `ActionController::ConditionalGet`. `http_cache_actions :index, :show, max_age: 5.minutes, visibility: :public, must_revalidate:, no_store:, stale_while_revalidate:, vary:` declares a policy (repeatable; no actions = catch-all; LAST matching rule wins — the Deprecatable override convention) emitted via `after_action`; `no_store` overrides `max_age`, `Vary` is appended (deduped) to any existing header, and the policy rides a 304. `stale_resource?(resource, etag:, last_modified:)` sets the validators and, for a safe (GET/HEAD) request whose precondition matches, sends `304 Not Modified` and returns false (mirrors `stale?`); `set_cache_validators` / `request_matches_cache?` / overridable `cache_etag_for` / `cache_last_modified_for` round out the surface. Standards-correct: weak ETag `W/"<md5>"` from the resource's cache key (collections fold member keys + size), `Last-Modified` via `Time#httpdate` (not the hand-rolled-ISO8601 bug), `If-None-Match` weak comparison honouring `*` and lists, `If-Modified-Since` compared at whole-second granularity, and `If-None-Match` taking precedence over `If-Modified-Since` (RFC 7232 §3.3). Every `request`/`response` touch is guarded so it runs on bare objects. Macro-time `ArgumentError` validation for `:visibility`, durations, `:vary`, and the booleans. Zero new runtime dependencies.
10
+
11
+ ## 1.19.0 (2026-06-13)
12
+
13
+ Two new concerns, selected by a unanimous three-judge design panel over six independently-proposed candidates (typed-JSON-settings and RFC-deprecation-headers each beat conditional counter caches, deep cloning, anonymization, params contracts, feature gates, and maintenance mode on value × shippability), then hardened in review (an `ActiveSupport::TimeWithZone` passed as `deprecated_at:`/`sunset_at:` — i.e. `Time.current` — was spuriously rejected as unparseable because `Module#===` ignores TimeWithZone's `is_a?(Time)` lie; a bare `Date` written to a `:datetime` key now anchors to midnight UTC instead of the host zone via `Date#to_time`; an unknown `header_format:` now raises at declaration time instead of silently emitting the RFC form). 894 examples, 0 failures.
14
+
15
+ ### Added
16
+ - **Models::Storable**: typed, defaulted, optionally-validated accessors over a single JSON-or-text column ("store_attribute-lite") — native `store_accessor` is untyped on every supported Rails version (a form-submitted `"true"` stays a String), has no defaults and no per-key dirty methods. `storable_by :settings, theme: { type: :string, default: "light", in: %w[light dark] }, ...` (repeatable — same column merges keys, columns independent, subclasses add keys without mutating the parent; `prefix:`/`suffix:` affix the generated names) gives a casting reader/writer, a `?` predicate (boolean keys), `_changed?`/`_was` computed per key against the column's own previous value, and `reset_<key>` (key removal → default applies again, distinct from an explicitly-written nil, which reads back as nil). Casting via `ActiveModel::Type` with JSON-safe representations: `:decimal` as a precision-safe String, `:datetime` as UTC ISO8601 with microseconds, `:date` as ISO8601, `:json` passthrough (reader returns a dup — reassign, don't mutate). The codec never uses `serialize` (sidestepping the Rails 7.1 kwarg drift entirely): a plain text column is JSON-encoded/decoded manually and tolerantly (corrupt JSON → `{}`, garbage values cast to nil, readers never raise), while native `json`/`jsonb` columns and host-app-`serialize`d columns are detected lazily and handed the Hash. Undeclared keys are preserved; string keys throughout; `in:` adds an inclusion validation on the (affixed) accessor name, nil/absent passing. Every generated name is collision-checked against methods and columns at macro time; key specs are shape- and type-validated (`ArgumentError`, ColumnGuard for the column). Whole-column dirty / last-write-wins semantics documented. Zero new runtime dependencies.
17
+ - **Controllers::Deprecatable**: standards-based API endpoint deprecation — RFC 9745 `Deprecation` (final structured-fields form `@<unix>`, or the widely-deployed pre-RFC draft literal `true` via `header_format: :legacy`), RFC 8594 `Sunset` (IMF-fixdate via `Time#httpdate`, not the classic hand-rolled ISO8601 bug), and RFC 8288 `Link` rels (`rel="deprecation"` migration docs + `rel="successor-version"`, appended to any existing Link header, never clobbered). `deprecate_actions :index, :show, deprecated_at:, sunset_at:, link:, successor:, after_sunset:, header_format:, notify:` — repeatable, inherited; no positional actions = whole-controller catch-all; the LAST matching rule wins (deliberately the reverse of Idempotentable's first-match: deprecation rules are configuration overrides, so a base-controller catch-all is naturally overridden by a later action-specific declaration) and exactly one Deprecation header is ever emitted. Times parse eagerly at declaration (`ArgumentError` on garbage; bare dates = 00:00 UTC — sunset is an instant; `sunset_at >= deprecated_at` enforced; TimeWithZone accepted). `after_sunset: :gone` (requires `sunset_at`) halts with 410 `endpoint_sunset` at/after the boundary instant — headers still ride the 410 so the cut-off self-documents — via Respondable's `render_error` when present, inline envelope otherwise; the default `:headers` never blocks. Every matching hit instruments `deprecated_endpoint.concerns_on_rails` (`ActiveSupport::Notifications`) and `instance_exec`s `notify:` (raising notify propagates — broken metrics should be loud) through the `on_deprecated_access(rule)` override point, so teams can measure stragglers before flipping enforcement. `deprecation_active?`/`sunset_passed?` predicates for serializers; one UTC clock seam (`deprecation_now`). Zero new runtime dependencies.
18
+
3
19
  ## 1.18.0 (2026-06-12)
4
20
 
5
21
  Two new concerns, designed and hardened through adversarial design- and code-review rounds (a shared-reflection registration strategy that produced invalid SQL was caught and replaced before implementation; a time-serialization path that silently lost sub-second precision likewise; a NULL page-boundary value that would have silently dropped rows now raises; a post-review pass fixed `has_many :through` aliasing, which crashed at macro time under lazy class loading and re-derived its `source:` from the alias name at query time). A follow-up enhancement round added bidirectional cursors, allow-listed order presets, and row-value predicates to CursorPaginatable, and `only:`/`except:`/`deprecated:`/`alias_foreign_key:` to Aliasable. 793 examples, 0 failures.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Ethan Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,5 +1,21 @@
1
+ <div align="center">
2
+
1
3
  # 🧩 ConcernsOnRails
2
4
 
5
+ **Plug-and-play ActiveSupport concerns for Rails models &amp; controllers.**<br/>
6
+ One `include`, one declarative macro — done.
7
+
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)
10
+ [![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)
11
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
12
+ [![Rails](https://img.shields.io/badge/rails-5.0--8.x-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-3fb950.svg)](#-license)
14
+
15
+ 🧩 **23 model concerns** &nbsp;·&nbsp; 🎮 **16 controller concerns** &nbsp;·&nbsp; 🪶 **lean deps** &nbsp;·&nbsp; ✅ **schema-validated**
16
+
17
+ </div>
18
+
3
19
  > 🇻🇳 **Hoàng Sa and Trường Sa belong to Việt Nam.**
4
20
 
5
21
  A plug-and-play collection of reusable ActiveSupport concerns for Rails **models** and **controllers** — slugs, soft delete, scheduled publish, expiry, sequential reference numbers, pagination, filtering, JSON envelopes, and more. One `include`, one declarative macro, done.
@@ -20,57 +36,62 @@ Article.published.without_deleted.find("hello-world")
20
36
 
21
37
  ## 📚 Table of Contents
22
38
 
23
- - [Why this gem?](#-why-this-gem)
24
- - [Installation](#-installation)
25
- - [Compatibility](#-compatibility)
26
- - [Quick Start](#-quick-start)
27
- - **Model concerns**
28
- - [Sluggable](#-sluggable) — URL-friendly slugs
29
- - [Sortable](#-sortable) list ordering via `acts_as_list`
30
- - [Publishable](#-publishable) `published_at` timestamp publishing
31
- - [SoftDeletable](#-softdeletable) soft delete with scopes & hooks
32
- - [Hashable](#-hashable) auto-generate tokens / UUIDs / codes
33
- - [Schedulable](#-schedulable) `starts_at` / `ends_at` time windows
34
- - [Expirable](#-expirable) single-timestamp expiry
35
- - [Normalizable](#-normalizable) attribute normalization (`:email`, `:phone`, …)
36
- - [Searchable](#-searchable) LIKE/ILIKE search across configured columns
37
- - [Activatable](#-activatable) boolean active/inactive toggle
38
- - [Tokenizable](#-tokenizable) security tokens with timing-safe lookup
39
- - [Sequenceable](#-sequenceable) ordered, human-friendly reference numbers
40
- - [Stateable](#-stateable) lightweight string-backed state machine
41
- - [Addressable](#-addressable) postal address normalization + format validation
42
- - [Taggable](#-taggable) lightweight tagging over a single column
43
- - [Sanitizable](#-sanitizable) opt-in HTML sanitization (XSS defense-in-depth)
44
- - [Maskable](#-maskable) non-destructive display masking of sensitive fields
45
- - [Monetizable](#-monetizable) integer-cents money columns (BigDecimal)
46
- - [Auditable](#-auditable) single-column change history ("paper_trail-lite")
47
- - [Lockable](#-lockable) failed-attempt tracking + account lockout
48
- - [Aliasable](#-aliasable) full read/write/query aliases for associations
49
- - **Controller concerns**
50
- - [Paginatable](#-paginatable) offset pagination with headers
51
- - [CursorPaginatable](#-cursorpaginatable) cursor (keyset) pagination with headers
52
- - [Filterable](#-filterable) — declarative URL-param filters
53
- - [Sortable (controller)](#-sortable-controller) — URL-param ordering with allow-list
54
- - [Respondable](#-respondable) — standardized JSON envelopes
55
- - [ErrorHandleable](#-errorhandleable) JSON `rescue_from` handlers for common controller errors
56
- - [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
57
- - [SecureHeadable](#-secureheadable) security response headers + native CSP DSL
58
- - [Localizable](#-localizable) per-request locale from params / Accept-Language
59
- - [Authorizable](#-authorizable) per-action 403 authorization gate (block-based)
60
- - [Throttleable](#-throttleable) rate limiting with 429 + `X-RateLimit-*` headers
61
- - [Timezoneable](#-timezoneable) per-request `Time.zone` from params / header / cookie
62
- - [Idempotentable](#-idempotentable) `Idempotency-Key` request replay (409 on concurrent duplicates)
63
- - [WebhookVerifiable](#-webhookverifiable) HMAC verification for inbound webhooks (Stripe/GitHub/Shopify)
64
- - [Module paths & namespacing](#-module-paths--namespacing)
65
- - [Development](#-development)
66
- - [Contributing](#-contributing)
67
- - [License](#-license)
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)
40
+
41
+ ### 🧱 Model concerns
42
+
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
+
69
+ ### 🎮 Controller concerns
70
+
71
+ | Concern | What it does |
72
+ |---------|--------------|
73
+ | [📄 Paginatable](#-paginatable) | Offset pagination with headers |
74
+ | [🧭 CursorPaginatable](#-cursorpaginatable) | Cursor (keyset) pagination with headers |
75
+ | [🔎 Filterable](#-filterable) | Declarative URL-param filters |
76
+ | [↕️ Sortable (controller)](#-sortable-controller) | URL-param ordering with allow-list |
77
+ | [📦 Respondable](#-respondable) | Standardized JSON envelopes |
78
+ | [🛟 ErrorHandleable](#-errorhandleable) | JSON `rescue_from` handlers |
79
+ | [🔗 Includable](#-includable) | Association sideloading + sparse fieldsets |
80
+ | [🛡️ SecureHeadable](#-secureheadable) | Security response headers + native CSP DSL |
81
+ | [🌐 Localizable](#-localizable) | Per-request locale from params / `Accept-Language` |
82
+ | [🔒 Authorizable](#-authorizable) | Per-action 403 authorization gate |
83
+ | [🚦 Throttleable](#-throttleable) | Rate limiting (429 + `X-RateLimit-*`) |
84
+ | [🕒 Timezoneable](#-timezoneable) | Per-request `Time.zone` from params / header / cookie |
85
+ | [🔁 Idempotentable](#-idempotentable) | `Idempotency-Key` request replay |
86
+ | [🪝 WebhookVerifiable](#-webhookverifiable) | HMAC verification for inbound webhooks |
87
+ | [🌅 Deprecatable](#-deprecatable) | RFC `Deprecation` / `Sunset` headers + 410 |
88
+ | [🗄️ Cacheable](#-cacheable) | HTTP conditional GET (ETag / 304) + `Cache-Control` |
68
89
 
69
90
  ---
70
91
 
71
92
  ## ✨ Why this gem?
72
93
 
73
- - **Twenty-one model concerns + fourteen controller concerns**, all production-ready
94
+ - **Twenty-three model concerns + sixteen controller concerns**, all production-ready
74
95
  - **One include, one macro** — no boilerplate, no glue code
75
96
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
76
97
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -1039,6 +1060,75 @@ Book.joins(:sections).where(sections: { title: "Intro" })
1039
1060
 
1040
1061
  ---
1041
1062
 
1063
+ ## ⚙️ Storable
1064
+
1065
+ Typed, defaulted, optionally-validated accessors over a **single JSON (or text) column** ("store_attribute-lite"). Rails' native `store_accessor` is untyped on every supported version — a form-submitted `"true"` stays the String `"true"` — with no defaults and no per-key dirty tracking; that gap is why the `store_attribute` / `jsonb_accessor` gems exist.
1066
+
1067
+ ```ruby
1068
+ class Account < ApplicationRecord
1069
+ include ConcernsOnRails::Storable
1070
+
1071
+ storable_by :settings,
1072
+ theme: { type: :string, default: "light", in: %w[light dark] },
1073
+ notifications: { type: :boolean, default: true },
1074
+ items_per_page: { type: :integer, default: 25 },
1075
+ trial_ends_at: { type: :datetime }
1076
+ storable_by :flags, { beta: { type: :boolean, default: false } }, prefix: :flag
1077
+ end
1078
+
1079
+ account.theme # => "light" (virtual default; nothing persisted)
1080
+ account.notifications = "0" # params arrive as strings…
1081
+ account.notifications # => false (…and read back cast)
1082
+ account.notifications? # boolean keys get a predicate
1083
+ account.items_per_page_changed? # per-key dirty (and items_per_page_was)
1084
+ account.reset_theme # drop the key → the default applies again
1085
+ account.flag_beta # affixed accessor
1086
+ ```
1087
+
1088
+ **Options** (per key): `type:` (`:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`, `:json`), `default:` (a value, or a Proc `instance_exec`'d per read), `in:` (inclusion validation, errors on the accessor name). Macro options: `prefix:` / `suffix:` affix the generated method names (the collision escape hatch). The macro is repeatable — repeat calls for the same column merge keys, different columns are independent, and subclasses can add keys without affecting the parent.
1089
+
1090
+ **Notes**
1091
+ - Works on a plain `text` column (JSON encoded/decoded internally), a native `json`/`jsonb` column, or a column the host app already `serialize`d — detected automatically. `serialize` itself is never used, so the Rails 7.1 API drift is irrelevant.
1092
+ - nil vs unset: a written `nil` (explicit JSON null) reads back as `nil` and does **not** fall back to the default; `reset_<key>` removes the key so the default applies again. `:decimal` is stored as a precision-safe string, `:date`/`:datetime` as ISO8601 (datetime in UTC at microsecond precision).
1093
+ - Writing one key dirties (and saves) the **whole column** — concurrent writers to different keys are last-write-wins on the hash. Undeclared keys are preserved. `:json` readers return a dup: reassign, don't mutate in place.
1094
+ - Generated names are collision-checked against existing methods and columns at macro time (`ArgumentError`; affix to escape). Read-side casting never raises — corrupt column JSON decodes as `{}`, garbage values cast to `nil`.
1095
+ - Reach for [`store_attribute`](https://github.com/palkan/store_attribute) / [`jsonb_accessor`](https://github.com/madeintandem/jsonb_accessor) when you need to **query** into the store (jsonb operators, store-backed scopes).
1096
+
1097
+ ---
1098
+
1099
+ ## 🧮 CounterCacheable
1100
+
1101
+ Conditional, denormalized association counters ("counter_culture-lite"). Rails' built-in `belongs_to ..., counter_cache: true` maintains exactly one column counting *every* child — it can't keep an `approved_comments_count` next to a `comments_count`, and has no way to repair drift. Declared on the **child**, this keeps one or many parent columns in sync, each with an optional condition.
1102
+
1103
+ ```ruby
1104
+ class Comment < ApplicationRecord
1105
+ include ConcernsOnRails::CounterCacheable
1106
+
1107
+ belongs_to :post # declare the belongs_to FIRST
1108
+ belongs_to :author, class_name: "User"
1109
+
1110
+ counter_cacheable_by :post # posts.comments_count
1111
+ counter_cacheable_by :post, count: :approved_comments_count,
1112
+ if: -> { approved? } # conditional
1113
+ counter_cacheable_by :author, count: :posts_count, touch: true
1114
+ end
1115
+
1116
+ post.comments_count # maintained on create / destroy / update
1117
+ Comment.recount_counter_caches! # repair drift / backfill every counter
1118
+ ```
1119
+
1120
+ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,0) ± 1`) inside the record's own save transaction. The update path handles the full matrix: a **foreign-key reparent** moves the count from the old parent to the new one, a **condition flip** increments/decrements in place, and the two compose.
1121
+
1122
+ **Options** (`counter_cacheable_by association, …`, repeatable): `count:` (the parent column; default `"<table_name>_count"`), `if:` (a callable evaluated against the record — counts only when truthy; the previous state is reconstructed for updates), `touch:` (`false`; also bump the parent's `updated_at`).
1123
+
1124
+ **Notes**
1125
+ - The `belongs_to` must be declared **before** the macro (the reflection is validated at declaration). Polymorphic associations are not supported.
1126
+ - Don't also set native `counter_cache: true` on the same column — both would fire and double-count.
1127
+ - Counters track the **persisted** record; writes that skip callbacks (`update_column(s)`, `update_all`, `delete`) are not tracked — run `recount_counter_caches!` to reconcile. It rewrites every parent (portable across adapters, but O(n) for conditional counters) — a maintenance operation, run it offline.
1128
+ - Reach for [`counter_culture`](https://github.com/magnusvk/counter_culture) when you need multi-level rollups, delta columns, or after-commit execution.
1129
+
1130
+ ---
1131
+
1042
1132
  # 🎮 Controller Concerns
1043
1133
 
1044
1134
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1504,6 +1594,76 @@ end
1504
1594
 
1505
1595
  ---
1506
1596
 
1597
+ ## 🌅 Deprecatable
1598
+
1599
+ Standards-based **API endpoint deprecation**: the RFC 9745 `Deprecation` and RFC 8594 `Sunset` headers, `Link` rels pointing at the migration docs and the successor endpoint, an instrumentation hook to measure who still calls the endpoint, and optional **410 Gone** enforcement once the sunset instant passes. This is how Stripe/GitHub/Zalando retire API versions — and nothing native exists on any Rails version.
1600
+
1601
+ ```ruby
1602
+ class Api::V1::OrdersController < ApplicationController
1603
+ include ConcernsOnRails::Controllers::Deprecatable
1604
+
1605
+ deprecate_actions :index, :show,
1606
+ deprecated_at: "2026-06-01",
1607
+ sunset_at: "2026-12-31T00:00:00Z",
1608
+ link: "https://docs.example.com/v1-migration",
1609
+ successor: "https://api.example.com/v2/orders",
1610
+ after_sunset: :gone, # default :headers — announce, never block
1611
+ notify: -> { StatsD.increment("api.v1.orders.deprecated") }
1612
+ end
1613
+
1614
+ # Every matching response then carries:
1615
+ # Deprecation: @1780272000
1616
+ # Sunset: Thu, 31 Dec 2026 00:00:00 GMT
1617
+ # Link: <https://docs.example.com/v1-migration>; rel="deprecation", <https://api.example.com/v2/orders>; rel="successor-version"
1618
+ ```
1619
+
1620
+ **Options**: `deprecated_at:` (required; Time/Date/String — parsed eagerly, normalized to UTC), `sunset_at:` (optional, must be ≥ `deprecated_at`; a bare date means **00:00 UTC that day** — sunset is an instant, not end-of-day), `link:` / `successor:` (URLs), `after_sunset:` (`:headers` default | `:gone` → 410 with code `endpoint_sunset` at/after the sunset instant), `header_format:` (`:rfc9745` default, `@<unix>` | `:legacy`, the widely-deployed draft literal `true`), `notify:` (callable, `instance_exec`'d per matching request — a raising notify propagates on purpose). No positional actions = catch-all for the whole controller. **The last matching rule wins**, so an action-specific declaration naturally overrides a base controller's catch-all.
1621
+
1622
+ **Notes**
1623
+ - Headers go out on every matching response — **including the 410 itself**, so the cut-off self-documents. `Link` values are appended to any existing `Link` header (pagination, CDN), never clobbered.
1624
+ - Each hit instruments `deprecated_endpoint.concerns_on_rails` (`ActiveSupport::Notifications`) with `{controller:, action:, deprecated_at:, sunset_at:}` — subscribe to count stragglers *before* flipping `after_sunset: :gone`. Override `on_deprecated_access(rule)` to replace the default instrumentation.
1625
+ - 410 bodies delegate to `Respondable`'s `render_error` when present (inline JSON envelope otherwise). `skip_before_action :apply_api_deprecations` opts an action out; `deprecation_active?` / `sunset_passed?` are available for serializers/response bodies.
1626
+ - Flipping `:gone` is a deliberate, customer-facing cut-off — coordinate it with `notify:`-driven outreach, and mind CDN-cached responses that may outlive the headers.
1627
+
1628
+ ---
1629
+
1630
+ ## 🗄️ Cacheable
1631
+
1632
+ HTTP conditional GET + declarative `Cache-Control` ("fresh_when/stale?-lite" for JSON APIs). The method names are deliberately distinct from Rails' `ActionController::ConditionalGet`, so including it never shadows `fresh_when` / `stale?` / `expires_in`.
1633
+
1634
+ ```ruby
1635
+ class Api::ArticlesController < ApplicationController
1636
+ include ConcernsOnRails::Controllers::Cacheable
1637
+
1638
+ http_cache_actions :index, :show, max_age: 5.minutes,
1639
+ visibility: :public, vary: "Accept"
1640
+
1641
+ def show
1642
+ @article = Article.find(params[:id])
1643
+ return unless stale_resource?(@article) # 304 + halt when the client copy is fresh
1644
+ render json: @article
1645
+ end
1646
+ end
1647
+
1648
+ # A matching response then carries:
1649
+ # Cache-Control: public, max-age=300
1650
+ # Vary: Accept
1651
+ # ETag: W/"…"
1652
+ # Last-Modified: Thu, 01 Jan 2026 12:00:00 GMT
1653
+ ```
1654
+
1655
+ `http_cache_actions` declares the `Cache-Control`/`Vary` policy (emitted via `after_action` — it rides a 304 too); `stale_resource?` sets the ETag/Last-Modified validators and, on a safe request whose precondition matches, sends `304 Not Modified` and returns `false`.
1656
+
1657
+ **Options** (`http_cache_actions *actions, …`, repeatable; no actions = catch-all; **last matching rule wins**): `visibility:` (`:private` default | `:public`), `max_age:` (Integer/Duration), `must_revalidate:`, `no_store:` (overrides everything → bare `no-store`), `stale_while_revalidate:`, `vary:` (String or Array, appended to any existing `Vary`).
1658
+
1659
+ **Conditional-GET correctness**
1660
+ - Weak ETag `W/"<md5>"` from the resource's cache key (collections fold their members' keys + size); `If-None-Match` is matched with **weak comparison**, honours `*`, and accepts a comma-separated list.
1661
+ - `Last-Modified` is an IMF-fixdate via `Time#httpdate` (not hand-rolled ISO 8601); `If-Modified-Since` is compared at whole-second granularity.
1662
+ - When both are sent, `If-None-Match` wins and the date is ignored (RFC 7232 §3.3); a 304 is only sent for safe (GET/HEAD) requests, and still carries the validators and the `Cache-Control` policy.
1663
+ - Override `cache_etag_for` / `cache_last_modified_for` to customise validator derivation. For write-side preconditions (`If-Match` → 412), reach for Rails' own helpers.
1664
+
1665
+ ---
1666
+
1507
1667
  ## 🗂️ Module paths & namespacing
1508
1668
 
1509
1669
  Every concern is available under two paths: